diff --git a/NuGet.config b/NuGet.config index fa18b73d78a..a59f8b5a05e 100644 --- a/NuGet.config +++ b/NuGet.config @@ -20,6 +20,7 @@ + @@ -37,6 +38,9 @@ + + + diff --git a/src/WatchPrototype/.editorconfig b/src/WatchPrototype/.editorconfig new file mode 100644 index 00000000000..54d2f34da8e --- /dev/null +++ b/src/WatchPrototype/.editorconfig @@ -0,0 +1,26 @@ +# EditorConfig to suppress warnings/errors for Watch solution + +root = false + +[*.cs] +# CA - Code Analysis warnings +dotnet_diagnostic.CA1305.severity = none # Specify IFormatProvider +dotnet_diagnostic.CA1822.severity = none # Mark members as static +dotnet_diagnostic.CA1835.severity = none # Prefer Memory-based overloads for ReadAsync/WriteAsync +dotnet_diagnostic.CA1852.severity = none # Seal internal types +dotnet_diagnostic.CA2007.severity = none # Do not directly await a Task +dotnet_diagnostic.CA2201.severity = none # Do not raise reserved exception types +dotnet_diagnostic.CA2008.severity = none # Do not create tasks without passing a TaskScheduler + +# CS - C# compiler warnings/errors +dotnet_diagnostic.CS1591.severity = none # Missing XML comment for publicly visible type or member +dotnet_diagnostic.CS1573.severity = none # Parameter 'sourceFile' has no matching param tag in the XML comment + +# IDE - IDE/Style warnings +dotnet_diagnostic.IDE0005.severity = none # Using directive is unnecessary +dotnet_diagnostic.IDE0011.severity = none # Add braces +dotnet_diagnostic.IDE0036.severity = none # Order modifiers +dotnet_diagnostic.IDE0060.severity = none # Remove unused parameter +dotnet_diagnostic.IDE0073.severity = none # File header does not match required text +dotnet_diagnostic.IDE0161.severity = none # Convert to file-scoped namespace +dotnet_diagnostic.IDE1006.severity = none # Naming rule violation diff --git a/src/WatchPrototype/AspireService/.editorconfig b/src/WatchPrototype/AspireService/.editorconfig new file mode 100644 index 00000000000..4694508c4da --- /dev/null +++ b/src/WatchPrototype/AspireService/.editorconfig @@ -0,0 +1,6 @@ +[*.cs] + +# IDE0240: Remove redundant nullable directive +# The directive needs to be included since all sources in a source package are considered generated code +# when referenced from a project via package reference. +dotnet_diagnostic.IDE0240.severity = none \ No newline at end of file diff --git a/src/WatchPrototype/AspireService/AspireServerService.cs b/src/WatchPrototype/AspireService/AspireServerService.cs new file mode 100644 index 00000000000..064de0ee7a5 --- /dev/null +++ b/src/WatchPrototype/AspireService/AspireServerService.cs @@ -0,0 +1,438 @@ +// 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.Net; +using System.Net.WebSockets; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using IAsyncDisposable = System.IAsyncDisposable; + +namespace Aspire.Tools.Service; + +/// +/// Implementation of the AspireServerService. A new instance of this service will be created for each +/// each call to IServiceBroker.CreateProxy() +/// +internal partial class AspireServerService : IAsyncDisposable +{ + public const string DebugSessionPortEnvVar = "DEBUG_SESSION_PORT"; + public const string DebugSessionTokenEnvVar = "DEBUG_SESSION_TOKEN"; + public const string DebugSessionServerCertEnvVar = "DEBUG_SESSION_SERVER_CERTIFICATE"; + + public const int PingIntervalInSeconds = 5; + + private readonly IAspireServerEvents _aspireServerEvents; + + private readonly Action? _reporter; + + private readonly string _currentSecret; + private readonly string _displayName; + + private readonly CancellationTokenSource _shutdownCancellationTokenSource = new(); + private readonly int _port; + private readonly X509Certificate2 _certificate; + private readonly string _certificateEncodedBytes; + + private readonly SemaphoreSlim _webSocketAccess = new(1); + + private readonly SocketConnectionManager _socketConnectionManager = new(); + + private volatile bool _isDisposed; + + private static readonly char[] s_charSeparator = { ' ' }; + + private readonly Task _requestListener; + + public static readonly JsonSerializerOptions JsonSerializerOptions = new() + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Converters = + { + new JsonStringEnumConverter(JsonNamingPolicy.CamelCase, allowIntegerValues: false) + } + }; + + public AspireServerService(IAspireServerEvents aspireServerEvents, string displayName, Action? reporter) + { + _aspireServerEvents = aspireServerEvents; + _reporter = reporter; + _displayName = displayName; + + _port = SocketUtilities.GetNextAvailablePort(); + + // Set up the encryption so we can use it to generate our secret. + var aes = Aes.Create(); + aes.Mode = CipherMode.CBC; + aes.KeySize = 128; + aes.Padding = PaddingMode.PKCS7; + aes.GenerateKey(); + _currentSecret = Convert.ToBase64String(aes.Key); + + _certificate = CertGenerator.GenerateCert(); + var certBytes = _certificate.Export(X509ContentType.Cert); + _certificateEncodedBytes = Convert.ToBase64String(certBytes); + + // Kick of the web server. + _requestListener = StartListeningAsync(); + } + + public async ValueTask DisposeAsync() + { + // Shutdown the service: + _shutdownCancellationTokenSource.Cancel(); + + Log("Waiting for server to shutdown ..."); + + try + { + await _requestListener; + } + catch (OperationCanceledException) + { + // nop + } + + _isDisposed = true; + + _socketConnectionManager.Dispose(); + _certificate.Dispose(); + _shutdownCancellationTokenSource.Dispose(); + } + + /// + public List> GetServerConnectionEnvironment() + => + [ + new(DebugSessionPortEnvVar, $"localhost:{_port}"), + new(DebugSessionTokenEnvVar, _currentSecret), + new(DebugSessionServerCertEnvVar, _certificateEncodedBytes), + ]; + + /// + public ValueTask NotifySessionEndedAsync(string dcpId, string sessionId, int processId, int? exitCode, CancellationToken cancelationToken) + => SendNotificationAsync( + new SessionTerminatedNotification() + { + NotificationType = NotificationType.SessionTerminated, + SessionId = sessionId, + Pid = processId, + ExitCode = exitCode + }, + dcpId, + sessionId, + cancelationToken); + + /// + public ValueTask NotifySessionStartedAsync(string dcpId, string sessionId, int processId, CancellationToken cancelationToken) + => SendNotificationAsync( + new ProcessRestartedNotification() + { + NotificationType = NotificationType.ProcessRestarted, + SessionId = sessionId, + PID = processId + }, + dcpId, + sessionId, + cancelationToken); + + /// + public ValueTask NotifyLogMessageAsync(string dcpId, string sessionId, bool isStdErr, string data, CancellationToken cancelationToken) + => SendNotificationAsync( + new ServiceLogsNotification() + { + NotificationType = NotificationType.ServiceLogs, + SessionId = sessionId, + IsStdErr = isStdErr, + LogMessage = data + }, + dcpId, + sessionId, + cancelationToken); + + /// + private async ValueTask SendNotificationAsync(TNotification notification, string dcpId, string sessionId, CancellationToken cancellationToken) + where TNotification : SessionNotification + { + try + { + Log($"[#{sessionId}] Sending '{notification.NotificationType}': {notification}"); + var jsonSerialized = JsonSerializer.SerializeToUtf8Bytes(notification, JsonSerializerOptions); + var success = await SendMessageAsync(dcpId, jsonSerialized, cancellationToken); + + if (!success) + { + cancellationToken.ThrowIfCancellationRequested(); + Log($"[#{sessionId}] Failed to send message: Connection not found (dcpId='{dcpId}')."); + } + } + catch (Exception e) when (e is not OperationCanceledException) + { + if (!cancellationToken.IsCancellationRequested) + { + Log($"[#{sessionId}] Failed to send message: {e.Message}"); + } + } + } + + /// + /// Waits for a connection so that it can get the WebSocket that will be used to send messages tio the client. It accepts messages via Restful http + /// calls. + /// + private Task StartListeningAsync() + { + var builder = WebApplication.CreateSlimBuilder(); + + builder.WebHost.ConfigureKestrel(kestrelOptions => + { + kestrelOptions.ListenLocalhost(_port, listenOptions => + { + listenOptions.UseHttps(_certificate); + }); + }); + + if (_reporter != null) + { + builder.Logging.ClearProviders(); + builder.Logging.AddProvider(new LoggerProvider(_reporter)); + } + + var app = builder.Build(); + + app.MapGet("/", () => _displayName); + app.MapGet(InfoResponse.Url, GetInfoAsync); + + // Set up the run session endpoints + var runSessionApi = app.MapGroup(RunSessionRequest.Url); + + runSessionApi.MapPut("/", RunSessionPutAsync); + runSessionApi.MapDelete("/{sessionId}", RunSessionDeleteAsync); + runSessionApi.Map(SessionNotification.Url, RunSessionNotifyAsync); + + app.UseWebSockets(new WebSocketOptions + { + KeepAliveInterval = TimeSpan.FromSeconds(PingIntervalInSeconds) + }); + + // Run the application async. It will shutdown when the cancel token is signaled + return app.RunAsync(_shutdownCancellationTokenSource.Token); + } + + private async Task RunSessionPutAsync(HttpContext context) + { + // Check the authentication header + if (!IsValidAuthentication(context)) + { + Log("Authorization failure"); + context.Response.StatusCode = (int)HttpStatusCode.Unauthorized; + } + else + { + await HandleStartSessionRequestAsync(context); + } + } + + private async Task RunSessionDeleteAsync(HttpContext context, string sessionId) + { + // Check the authentication header + if (!IsValidAuthentication(context)) + { + Log("Authorization failure"); + context.Response.StatusCode = (int)HttpStatusCode.Unauthorized; + } + else + { + await HandleStopSessionRequestAsync(context, sessionId); + } + } + + private async Task GetInfoAsync(HttpContext context) + { + // Check the authentication header + if (!IsValidAuthentication(context)) + { + Log("Authorization failure"); + context.Response.StatusCode = (int)HttpStatusCode.Unauthorized; + } + else + { + context.Response.StatusCode = (int)HttpStatusCode.OK; + await context.Response.WriteAsJsonAsync(InfoResponse.Instance, JsonSerializerOptions, _shutdownCancellationTokenSource.Token); + } + } + + private async Task RunSessionNotifyAsync(HttpContext context) + { + // Check the authentication header + if (!IsValidAuthentication(context)) + { + Log("Authorization failure"); + context.Response.StatusCode = (int)HttpStatusCode.Unauthorized; + return; + } + else if (!context.WebSockets.IsWebSocketRequest) + { + context.Response.StatusCode = StatusCodes.Status400BadRequest; + return; + } + + var webSocket = await context.WebSockets.AcceptWebSocketAsync(); + var socketTcs = new TaskCompletionSource(); + + // Track this connection. + _socketConnectionManager.AddSocketConnection(webSocket, socketTcs, context.GetDcpId(), context.RequestAborted); + + // We must keep the middleware pipeline alive for the duration of the socket + await socketTcs.Task; + } + + private void Log(string message) + { + _reporter?.Invoke(message); + } + + private bool IsValidAuthentication(HttpContext context) + { + // Check the authentication header + var authHeader = context.Request.Headers.Authorization; + if (authHeader.Count == 1) + { + var authTokens = authHeader[0]!.Split(s_charSeparator, StringSplitOptions.RemoveEmptyEntries); + + return authTokens.Length == 2 && + string.Equals(authTokens[0], "Bearer", StringComparison.Ordinal) && + string.Equals(authTokens[1], _currentSecret, StringComparison.Ordinal); + } + + return false; + } + + private async Task HandleStartSessionRequestAsync(HttpContext context) + { + string? projectPath = null; + + try + { + if (_isDisposed) + { + throw new ObjectDisposedException(nameof(AspireServerService), "Received 'PUT /run_session' request after the service has been disposed."); + } + + // Get the project launch request data + var projectLaunchRequest = await context.GetProjectLaunchInformationAsync(_shutdownCancellationTokenSource.Token); + if (projectLaunchRequest == null) + { + // Unknown or unsupported version + context.Response.StatusCode = (int)HttpStatusCode.BadRequest; + return; + } + + projectPath = projectLaunchRequest.ProjectPath; + + var sessionId = await _aspireServerEvents.StartProjectAsync(context.GetDcpId(), projectLaunchRequest, _shutdownCancellationTokenSource.Token); + + context.Response.StatusCode = (int)HttpStatusCode.Created; + context.Response.Headers.Location = $"{context.Request.Scheme}://{context.Request.Host}{context.Request.Path}/{sessionId}"; + } + catch (Exception e) when (e is not OperationCanceledException) + { + Log($"Failed to start project{(projectPath == null ? "" : $" '{projectPath}'")}: {e}"); + + context.Response.StatusCode = (int)HttpStatusCode.InternalServerError; + await WriteResponseTextAsync(context.Response, e, context.GetApiVersion() is not null); + } + } + + private async Task WriteResponseTextAsync(HttpResponse response, Exception ex, bool useRichErrorResponse) + { + byte[] errorResponse; + if (useRichErrorResponse) + { + // If the exception is a webtools one, use the failure bucket strings as the error Code + string? errorCode = null; + + var error = new ErrorResponse() + { + Error = new ErrorDetail { ErrorCode = errorCode, Message = ex.GetMessageFromException() } + }; + + await response.WriteAsJsonAsync(error, JsonSerializerOptions, _shutdownCancellationTokenSource.Token); + } + else + { + errorResponse = Encoding.UTF8.GetBytes(ex.GetMessageFromException()); + response.ContentType = "text/plain"; + response.ContentLength = errorResponse.Length; + await response.WriteAsync(ex.GetMessageFromException(), _shutdownCancellationTokenSource.Token); + } + } + + private async ValueTask SendMessageAsync(string dcpId, byte[] messageBytes, CancellationToken cancellationToken) + { + // Find the connection for the passed in dcpId + WebSocketConnection? connection = _socketConnectionManager.GetSocketConnection(dcpId); + if (connection is null) + { + return false; + } + + var success = false; + try + { + using var cancelTokenSource = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, _shutdownCancellationTokenSource.Token, connection.HttpRequestAborted); + + await _webSocketAccess.WaitAsync(cancelTokenSource.Token); + await connection.Socket.SendAsync(new ArraySegment(messageBytes), WebSocketMessageType.Text, endOfMessage: true, cancelTokenSource.Token); + + success = true; + } + finally + { + if (!success) + { + // If the connection throws it almost certainly means the client has gone away, so clean up that connection + _socketConnectionManager.RemoveSocketConnection(connection); + } + + _webSocketAccess.Release(); + } + + return success; + } + + private async ValueTask HandleStopSessionRequestAsync(HttpContext context, string sessionId) + { + try + { + if (_isDisposed) + { + throw new ObjectDisposedException(nameof(AspireServerService), "Received 'DELETE /run_session' request after the service has been disposed."); + } + + var sessionExists = await _aspireServerEvents.StopSessionAsync(context.GetDcpId(), sessionId, _shutdownCancellationTokenSource.Token); + context.Response.StatusCode = (int)(sessionExists ? HttpStatusCode.OK : HttpStatusCode.NoContent); + } + catch (Exception e) when (e is not OperationCanceledException) + { + Log($"[#{sessionId}] Failed to stop: {e}"); + + context.Response.StatusCode = (int)HttpStatusCode.InternalServerError; + await WriteResponseTextAsync(context.Response, e, context.GetApiVersion() is not null); + } + } +} diff --git a/src/WatchPrototype/AspireService/Contracts/IAspireServerEvents.cs b/src/WatchPrototype/AspireService/Contracts/IAspireServerEvents.cs new file mode 100644 index 00000000000..65d43f2ae76 --- /dev/null +++ b/src/WatchPrototype/AspireService/Contracts/IAspireServerEvents.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. + +#nullable enable + +using System.Threading; +using System.Threading.Tasks; +using System.Collections.Generic; + +namespace Aspire.Tools.Service; + +internal interface IAspireServerEvents +{ + /// + /// Called when a request to stop a session is received. + /// + /// The id of the session to terminate. The session might have been stopped already. + /// DCP/AppHost making the request. May be empty for older DCP versions. + /// Returns false if the session is not active. + ValueTask StopSessionAsync(string dcpId, string sessionId, CancellationToken cancellationToken); + + /// + /// Called when a request to start a project is received. Returns the session id of the started project. + /// + /// DCP/AppHost making the request. May be empty for older DCP versions. + /// New unique session id. + ValueTask StartProjectAsync(string dcpId, ProjectLaunchRequest projectLaunchInfo, CancellationToken cancellationToken); +} + +internal class ProjectLaunchRequest +{ + public string ProjectPath { get; set; } = string.Empty; + public bool Debug { get; set; } + public IEnumerable>? Environment { get; set; } + public IEnumerable? Arguments { get; set; } + public string? LaunchProfile { get; set; } + public bool DisableLaunchProfile { get; set; } +} diff --git a/src/WatchPrototype/AspireService/Helpers/CertGenerator.cs b/src/WatchPrototype/AspireService/Helpers/CertGenerator.cs new file mode 100644 index 00000000000..3631bec9911 --- /dev/null +++ b/src/WatchPrototype/AspireService/Helpers/CertGenerator.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. + +#nullable enable + +using System; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; + +namespace Aspire.Tools.Service; + +internal static class CertGenerator +{ + public static X509Certificate2 GenerateCert() + { + const int rsaKeySize = 2048; + var rsa = RSA.Create(rsaKeySize); // Create asymmetric RSA key pair. + var req = new CertificateRequest( + "cn=debug-session.visualstudio.microsoft.com", + rsa, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pss + ); + + var sanBuilder = new SubjectAlternativeNameBuilder(); + sanBuilder.AddDnsName("localhost"); + req.CertificateExtensions.Add(sanBuilder.Build()); + + var cert = req.CreateSelfSigned( + DateTimeOffset.UtcNow.AddSeconds(-5), + DateTimeOffset.UtcNow.AddDays(7) + ); + + if (OperatingSystem.IsWindows()) + { + // Workaround for Windows S/Channel requirement for storing private for the certificate on disk. + // The file will be automatically generated by the following call and disposed when the returned cert is disposed. + using (cert) + { +#if NET9_0_OR_GREATER + return X509CertificateLoader.LoadPkcs12(cert.Export(X509ContentType.Pfx), password: null, X509KeyStorageFlags.UserKeySet); +#else + return new X509Certificate2(cert.Export(X509ContentType.Pfx), "", X509KeyStorageFlags.UserKeySet); +#endif + } + } + else + { + return cert; + } + } +} diff --git a/src/WatchPrototype/AspireService/Helpers/ExceptionExtensions.cs b/src/WatchPrototype/AspireService/Helpers/ExceptionExtensions.cs new file mode 100644 index 00000000000..39dd2204546 --- /dev/null +++ b/src/WatchPrototype/AspireService/Helpers/ExceptionExtensions.cs @@ -0,0 +1,46 @@ +// 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; + +namespace Aspire.Tools.Service; + +internal static class ExceptionExtensions +{ + /// + /// Given an exception, returns a string which has concatenated the ex.message and inner exception message + /// if it exits. If it is an aggregate exception it concatenates all the exceptions that are in the aggregate + /// + public static string GetMessageFromException(this Exception ex) + { + string msg = string.Empty; + if (ex is AggregateException aggException) + { + foreach (var e in aggException.Flatten().InnerExceptions) + { + if (msg == string.Empty) + { + msg = e.Message; + } + else + { + msg += " "; + msg += e.Message; + } + } + } + else + { + msg = ex.Message; + if (ex.InnerException != null) + { + msg += " "; + msg += ex.InnerException.Message; + } + } + + return msg; + } +} diff --git a/src/WatchPrototype/AspireService/Helpers/HttpContextExtensions.cs b/src/WatchPrototype/AspireService/Helpers/HttpContextExtensions.cs new file mode 100644 index 00000000000..bc274cbde21 --- /dev/null +++ b/src/WatchPrototype/AspireService/Helpers/HttpContextExtensions.cs @@ -0,0 +1,58 @@ +// 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.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; + +namespace Aspire.Tools.Service; + +internal static class HttpContextExtensions +{ + public const string VersionQueryString = "api-version"; + public const string DCPInstanceIDHeader = "Microsoft-Developer-DCP-Instance-ID"; + public static DateTime SupportedVersionAsDate = DateTime.Parse(RunSessionRequest.SupportedProtocolVersion); + + public static string? GetApiVersion(this HttpContext context) + { + return context.Request.Query[VersionQueryString]; + } + + /// + /// Looks for the dcp instance ID header and returns the id, or the empty string + /// + /// + /// + public static string GetDcpId(this HttpContext context) + { + // return the header value. + var dcpHeader = context.Request.Headers[DCPInstanceIDHeader]; + if (dcpHeader.Count == 1) + { + return dcpHeader[0]?? string.Empty; + } + + return string.Empty; + } + + /// + /// Deserializes the payload depending on the protocol version and returns the normalized ProjectLaunchRequest. Returns null if the + /// protocol version is not known or older. Throws if there is a serialization failure + /// + public static async Task GetProjectLaunchInformationAsync(this HttpContext context, CancellationToken cancelToken) + { + // Get the version querystring if there is one. Reject any requests w/o a supported version + var versionString = context.GetApiVersion(); + if (versionString is not null && DateTime.TryParse(versionString, out var version) && version >= SupportedVersionAsDate) + { + var runSessionRequest = await context.Request.ReadFromJsonAsync(AspireServerService.JsonSerializerOptions, cancelToken); + return runSessionRequest?.ToProjectLaunchInformation(); + } + + // Unknown or older version. + return null; + } +} diff --git a/src/WatchPrototype/AspireService/Helpers/LoggerProvider.cs b/src/WatchPrototype/AspireService/Helpers/LoggerProvider.cs new file mode 100644 index 00000000000..eadd09d1d87 --- /dev/null +++ b/src/WatchPrototype/AspireService/Helpers/LoggerProvider.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; +using Microsoft.Extensions.Logging; + +namespace Aspire.Tools.Service; + +internal sealed class LoggerProvider(Action reporter) : ILoggerProvider +{ + private sealed class Logger(Action reporter) : ILogger + { + public IDisposable? BeginScope(TState state) where TState : notnull + => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + var message = formatter(state, exception); + if (!string.IsNullOrEmpty(message)) + { + reporter(message); + } + } + } + + public void Dispose() + { + } + + public Action Reporter + => reporter; + + public ILogger CreateLogger(string categoryName) + => new Logger(reporter); +} diff --git a/src/WatchPrototype/AspireService/Helpers/SocketConnectionManager.cs b/src/WatchPrototype/AspireService/Helpers/SocketConnectionManager.cs new file mode 100644 index 00000000000..13d77fee3a5 --- /dev/null +++ b/src/WatchPrototype/AspireService/Helpers/SocketConnectionManager.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. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Net.WebSockets; +using System.Threading; +using System.Threading.Tasks; + +namespace Aspire.Tools.Service; + +/// +/// Manages the set of active socket connections. Since it registers to be notified when a socket has gone bad, +/// it also tracks those CancellationTokenRegistration objects so they can be disposed +/// +internal class SocketConnectionManager : IDisposable +{ + // Track a single connection per Dcp ID + private readonly object _socketConnectionsLock = new(); + private readonly Dictionary _webSocketConnections = new(StringComparer.Ordinal); + + private void CleanupSocketConnections() + { + lock (_socketConnectionsLock) + { + foreach (var connection in _webSocketConnections) + { + connection.Value.Tcs.SetResult(); + connection.Value.CancelTokenRegistration.Dispose(); + } + + _webSocketConnections.Clear(); + } + } + + public void AddSocketConnection(WebSocket socket, TaskCompletionSource tcs, string dcpId, CancellationToken httpRequestAborted) + { + // We only support one connection per DCP Id, therefore if there is + // already a connection, drop that one before adding this one + lock (_socketConnectionsLock) + { + if (_webSocketConnections.TryGetValue(dcpId, out var existingConnection)) + { + _webSocketConnections.Remove(dcpId); + existingConnection.Dispose(); + } + + // Register with the cancel token so that if the socket goes bad, we + // get notified and can remove it from our list. We need to track the registrations as well + // so we can dispose of it later + var newConnection = new WebSocketConnection(socket, tcs, dcpId, httpRequestAborted); + newConnection.CancelTokenRegistration = httpRequestAborted.Register(() => + { + RemoveSocketConnection(newConnection); + }); + + _webSocketConnections[dcpId] = newConnection; + } + } + + public void RemoveSocketConnection(WebSocketConnection connection) + { + lock (_socketConnectionsLock) + { + _webSocketConnections.Remove(connection.DcpId); + connection.Dispose(); + } + } + + public WebSocketConnection? GetSocketConnection(string dcpId) + { + lock (_socketConnectionsLock) + { + _webSocketConnections.TryGetValue(dcpId, out var connection); + return connection; + } + } + + public void Dispose() + { + CleanupSocketConnections(); + } +} diff --git a/src/WatchPrototype/AspireService/Helpers/SocketUtilities.cs b/src/WatchPrototype/AspireService/Helpers/SocketUtilities.cs new file mode 100644 index 00000000000..c2569d846bd --- /dev/null +++ b/src/WatchPrototype/AspireService/Helpers/SocketUtilities.cs @@ -0,0 +1,94 @@ +// 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.Linq; +using System.Collections.Generic; +using System.Net; +using System.Net.Sockets; + +namespace Aspire.Tools.Service; + +internal class SocketUtilities +{ + /// + /// Unsafe ports as defined by chrome (http://superuser.com/questions/188058/which-ports-are-considered-unsafe-on-chrome) + /// + private static readonly int[] s_unsafePorts = new int[] { + 2049, // nfs + 3659, // apple-sasl / PasswordServer + 4045, // lockd + 6000, // X11 + 6665, // Alternate IRC [Apple addition] + 6666, // Alternate IRC [Apple addition] + 6667, // Standard IRC [Apple addition] + 6668, // Alternate IRC [Apple addition] + 6669, // Alternate IRC [Apple addition] + }; + + /// + /// Get the next available dynamic port + /// + public static int GetNextAvailablePort() + { + var ports = GetNextAvailablePorts(1); + return ports == null ? 0 : ports[0]; + } + + /// + /// Get a list of available dynamic ports. Max that can be retrieved is 10 + /// + public static int[]? GetNextAvailablePorts(int countOfPorts) + { + // Creates the Socket to send data over a TCP connection. + var ports = GetNextAvailablePorts(countOfPorts, AddressFamily.InterNetwork); + ports ??= GetNextAvailablePorts(countOfPorts, AddressFamily.InterNetworkV6); + return ports; + } + + /// + /// Get a list of available dynamic ports for the addressFamily. + /// + private static int[]? GetNextAvailablePorts(int countOfPorts, AddressFamily addressFamily) + { + // Creates the Socket to send data over a TCP connection. + var sockets = new List(); + try + { + var ports = new int[countOfPorts]; + for (int i = 0; i < countOfPorts; i++) + { + Socket socket = new Socket(addressFamily, SocketType.Stream, ProtocolType.Tcp); + sockets.Add(socket); + IPEndPoint endPoint = new IPEndPoint(addressFamily == AddressFamily.InterNetworkV6 ? IPAddress.IPv6Any : IPAddress.Any, 0); + socket.Bind(endPoint); + var endPointUsed = (IPEndPoint?)socket.LocalEndPoint; + if (endPointUsed is not null && !s_unsafePorts.Contains(endPointUsed.Port)) + { + ports[i] = endPointUsed.Port; + } + else + { // Need to try this one again + --i; + } + } + + return ports; + } + catch (SocketException) + { + } + finally + { + foreach (var socket in sockets) + { + socket.Dispose(); + } + sockets.Clear(); + } + + return null; + } +} diff --git a/src/WatchPrototype/AspireService/Helpers/WebSocketConnection.cs b/src/WatchPrototype/AspireService/Helpers/WebSocketConnection.cs new file mode 100644 index 00000000000..81d74d1a6e2 --- /dev/null +++ b/src/WatchPrototype/AspireService/Helpers/WebSocketConnection.cs @@ -0,0 +1,37 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable enable + +using System; +using System.Threading; +using System.Threading.Tasks; +using System.Net.WebSockets; + +namespace Aspire.Tools.Service; + +/// +/// Used by the SocketConnectionManager to track one socket connection. It needs to be disposed when done with it +/// +internal class WebSocketConnection : IDisposable +{ + public WebSocketConnection(WebSocket socket, TaskCompletionSource tcs, string dcpId, CancellationToken httpRequestAborted) + { + Socket = socket; + Tcs = tcs; + DcpId = dcpId; + HttpRequestAborted = httpRequestAborted; + } + + public WebSocket Socket { get; } + public TaskCompletionSource Tcs { get; } + public string DcpId { get; } + public CancellationToken HttpRequestAborted { get; } + public CancellationTokenRegistration CancelTokenRegistration { get; set; } + + public void Dispose() + { + Tcs.SetResult(); + CancelTokenRegistration.Dispose(); + } +} diff --git a/src/WatchPrototype/AspireService/Microsoft.WebTools.AspireService.Package.csproj b/src/WatchPrototype/AspireService/Microsoft.WebTools.AspireService.Package.csproj new file mode 100644 index 00000000000..2c32befe16b --- /dev/null +++ b/src/WatchPrototype/AspireService/Microsoft.WebTools.AspireService.Package.csproj @@ -0,0 +1,30 @@ + + + + $(VisualStudioServiceTargetFramework) + false + none + false + preview + + + true + true + true + Aspire.Tools.Service + false + + Package containing sources of a service that implements DCP protocol. + + + $(NoWarn);NU5128 + + + + + + + + + + diff --git a/src/WatchPrototype/AspireService/Microsoft.WebTools.AspireService.projitems b/src/WatchPrototype/AspireService/Microsoft.WebTools.AspireService.projitems new file mode 100644 index 00000000000..2fa482c32f7 --- /dev/null +++ b/src/WatchPrototype/AspireService/Microsoft.WebTools.AspireService.projitems @@ -0,0 +1,26 @@ + + + + $(MSBuildAllProjects);$(MSBuildThisFileFullPath) + true + 94c8526e-dcc2-442f-9868-3dd0ba2688be + + + Microsoft.WebTools.AspireService + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/WatchPrototype/AspireService/Microsoft.WebTools.AspireService.shproj b/src/WatchPrototype/AspireService/Microsoft.WebTools.AspireService.shproj new file mode 100644 index 00000000000..f6208ac2637 --- /dev/null +++ b/src/WatchPrototype/AspireService/Microsoft.WebTools.AspireService.shproj @@ -0,0 +1,13 @@ + + + + 94c8526e-dcc2-442f-9868-3dd0ba2688be + 14.0 + + + + + + + + diff --git a/src/WatchPrototype/AspireService/Models/ErrorResponse.cs b/src/WatchPrototype/AspireService/Models/ErrorResponse.cs new file mode 100644 index 00000000000..86ee670f7d8 --- /dev/null +++ b/src/WatchPrototype/AspireService/Models/ErrorResponse.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.Text.Json.Serialization; + +namespace Aspire.Tools.Service; + +/// +/// Detailed error information serialized into the body of the response +/// +internal class ErrorResponse +{ + [JsonPropertyName("error")] + public ErrorDetail? Error { get; set; } +} + +internal class ErrorDetail +{ + [JsonPropertyName("code")] + public string? ErrorCode { get; set; } + + [JsonPropertyName("message")] + public string? Message { get; set; } + + [JsonPropertyName("details")] + public ErrorDetail[]? MessageDetails { get; set; } +} diff --git a/src/WatchPrototype/AspireService/Models/InfoResponse.cs b/src/WatchPrototype/AspireService/Models/InfoResponse.cs new file mode 100644 index 00000000000..cb848b47a2d --- /dev/null +++ b/src/WatchPrototype/AspireService/Models/InfoResponse.cs @@ -0,0 +1,25 @@ +// 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.Text.Json.Serialization; + +namespace Aspire.Tools.Service; + +/// +/// Response when asked for /info +/// +internal class InfoResponse +{ + public const string Url = "/info"; + + [JsonPropertyName("protocols_supported")] + public string[]? ProtocolsSupported { get; set; } + + public static InfoResponse Instance = new () {ProtocolsSupported = new string[] + { + RunSessionRequest.OurProtocolVersion, + RunSessionRequest.SupportedProtocolVersion + }}; +} diff --git a/src/WatchPrototype/AspireService/Models/RunSessionRequest.cs b/src/WatchPrototype/AspireService/Models/RunSessionRequest.cs new file mode 100644 index 00000000000..6290ee5e608 --- /dev/null +++ b/src/WatchPrototype/AspireService/Models/RunSessionRequest.cs @@ -0,0 +1,86 @@ +// 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.ComponentModel.DataAnnotations; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text.Json.Serialization; + +namespace Aspire.Tools.Service; + +internal class EnvVar +{ + [Required] + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("value")] + public string? Value { get; set; } +} + +internal class LaunchConfiguration +{ + [Required] + [JsonPropertyName("type")] + public string LaunchType { get; set; } = string.Empty; + + [Required] + [JsonPropertyName("project_path")] + public string ProjectPath { get; set; } = string.Empty; + + [JsonPropertyName("launch_profile")] + public string? LaunchProfile { get; set; } + + [JsonPropertyName("disable_launch_profile")] + public bool DisableLaunchProfile { get; set; } + + [JsonPropertyName("mode")] + public string LaunchMode { get; set; } = string.Empty; +} + +internal class RunSessionRequest +{ + public const string Url = "/run_session"; + public const string VersionQuery = "api-version"; + public const string OurProtocolVersion = "2024-04-23"; // This means we support socket ping-pong keepalive + public const string SupportedProtocolVersion = "2024-03-03"; + public const string ProjectLaunchConfigurationType = "project"; + public const string NoDebugLaunchMode = "NoDebug"; + public const string DebugLaunchMode = "Debug"; + + [Required] + [JsonPropertyName("launch_configurations")] + public LaunchConfiguration[] LaunchConfigurations { get; set; } = []; + + [JsonPropertyName("env")] + public EnvVar[] Environment { get; set; } = []; + + [JsonPropertyName("args")] + public string[]? Arguments { get; set; } + + public ProjectLaunchRequest? ToProjectLaunchInformation() + { + // Only support one launch project request. Ignoring all others + Debug.Assert(LaunchConfigurations.Length == 1, $"Unexpected number of launch configurations {LaunchConfigurations.Length}"); + + var projectLaunchConfig = LaunchConfigurations.FirstOrDefault(launchConfig => string.Equals(launchConfig.LaunchType, ProjectLaunchConfigurationType, StringComparison.OrdinalIgnoreCase)); + if (projectLaunchConfig is null) + { + return null; + } + + return new ProjectLaunchRequest() + { + ProjectPath = projectLaunchConfig.ProjectPath, + Debug = string.Equals(projectLaunchConfig.LaunchMode, DebugLaunchMode, StringComparison.OrdinalIgnoreCase), + Arguments = Arguments, + Environment = Environment.Select(envVar => new KeyValuePair(envVar.Name, envVar.Value ?? "")), + LaunchProfile = projectLaunchConfig.LaunchProfile, + DisableLaunchProfile = projectLaunchConfig.DisableLaunchProfile + }; + } +} diff --git a/src/WatchPrototype/AspireService/Models/SessionChangeNotification.cs b/src/WatchPrototype/AspireService/Models/SessionChangeNotification.cs new file mode 100644 index 00000000000..d4213593cf5 --- /dev/null +++ b/src/WatchPrototype/AspireService/Models/SessionChangeNotification.cs @@ -0,0 +1,103 @@ +// 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.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; + +namespace Aspire.Tools.Service; + +internal static class NotificationType +{ + public const string ProcessRestarted = "processRestarted"; + public const string SessionTerminated = "sessionTerminated"; + public const string ServiceLogs = "serviceLogs"; +} + +/// +/// Implements https://github.com/dotnet/aspire/blob/445d2fc8a6a0b7ce3d8cc42def4d37b02709043b/docs/specs/IDE-execution.md#common-notification-properties. +/// +internal class SessionNotification +{ + public const string Url = "/notify"; + + /// + /// One of . + /// + [Required] + [JsonPropertyName("notification_type")] + public required string NotificationType { get; init; } + + /// + /// The id of the run session that the notification is related to. + /// + [Required] + [JsonPropertyName("session_id")] + public required string SessionId { get; init; } +} + +/// +/// Implements https://github.com/dotnet/aspire/blob/445d2fc8a6a0b7ce3d8cc42def4d37b02709043b/docs/specs/IDE-execution.md#session-terminated-notification. +/// is . +/// +internal sealed class SessionTerminatedNotification : SessionNotification +{ + /// + /// The process id of the service process associated with the run session. + /// + [Required] + [JsonPropertyName("pid")] + public required int Pid { get; init; } + + /// + /// The exit code of the process associated with the run session. + /// + [Required] + [JsonPropertyName("exit_code")] + public required int? ExitCode { get; init; } + + public override string ToString() + => $"pid={Pid}, exit_code={ExitCode}"; +} + +/// +/// Implements https://github.com/dotnet/aspire/blob/445d2fc8a6a0b7ce3d8cc42def4d37b02709043b/docs/specs/IDE-execution.md#process-restarted-notification. +/// is . +/// +internal sealed class ProcessRestartedNotification : SessionNotification +{ + /// + /// The process id of the service process associated with the run session. + /// + [Required] + [JsonPropertyName("pid")] + public required int PID { get; init; } + + public override string ToString() + => $"pid={PID}"; +} + +/// +/// Implements https://github.com/dotnet/aspire/blob/445d2fc8a6a0b7ce3d8cc42def4d37b02709043b/docs/specs/IDE-execution.md#log-notification +/// is . +/// +internal sealed class ServiceLogsNotification : SessionNotification +{ + /// + /// True if the output comes from standard error stream, otherwise false (implying standard output stream). + /// + [Required] + [JsonPropertyName("is_std_err")] + public required bool IsStdErr { get; init; } + + /// + /// The text written by the service program. + /// + [Required] + [JsonPropertyName("log_message")] + public required string LogMessage { get; init; } + + public override string ToString() + => $"log_message='{LogMessage}', is_std_err={IsStdErr}"; +} diff --git a/src/WatchPrototype/BrowserRefresh/Microsoft.AspNetCore.Watch.BrowserRefresh.csproj b/src/WatchPrototype/BrowserRefresh/Microsoft.AspNetCore.Watch.BrowserRefresh.csproj new file mode 100644 index 00000000000..4aeac077f07 --- /dev/null +++ b/src/WatchPrototype/BrowserRefresh/Microsoft.AspNetCore.Watch.BrowserRefresh.csproj @@ -0,0 +1,34 @@ + + + + net6.0 + true + + MicrosoftAspNetCore + + + + + + + + + + + + + + + + + + + + + %(FileName)%(Extension) + + + diff --git a/src/WatchPrototype/BrowserRefresh/Properties/AssemblyInfo.cs b/src/WatchPrototype/BrowserRefresh/Properties/AssemblyInfo.cs new file mode 100644 index 00000000000..e56a4a5d592 --- /dev/null +++ b/src/WatchPrototype/BrowserRefresh/Properties/AssemblyInfo.cs @@ -0,0 +1,6 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Watch.BrowserRefresh.Tests, PublicKey = 0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] diff --git a/src/WatchPrototype/Common/PathUtilities.cs b/src/WatchPrototype/Common/PathUtilities.cs new file mode 100644 index 00000000000..250098acea0 --- /dev/null +++ b/src/WatchPrototype/Common/PathUtilities.cs @@ -0,0 +1,8 @@ +// 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 new file mode 100644 index 00000000000..e1dbcf6c450 --- /dev/null +++ b/src/WatchPrototype/Directory.Build.props @@ -0,0 +1,35 @@ + + + + + false + true + net472 + net8.0 + net10.0 + + + + + + + + + + 9.0.0 + 2.0.0-preview.1.24427.4 + 9.0.0 + 9.0.0 + 4.5.1 + 9.0.0 + 4.5.5 + 9.0.0 + 9.0.0 + 9.0.0 + 8.0.0 + 9.0.0 + 4.5.4 + 8.0.0 + + diff --git a/src/WatchPrototype/Directory.Packages.props b/src/WatchPrototype/Directory.Packages.props new file mode 100644 index 00000000000..7ca24acc6cf --- /dev/null +++ b/src/WatchPrototype/Directory.Packages.props @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/src/WatchPrototype/DotNetDeltaApplier/Microsoft.Extensions.DotNetDeltaApplier.csproj b/src/WatchPrototype/DotNetDeltaApplier/Microsoft.Extensions.DotNetDeltaApplier.csproj new file mode 100644 index 00000000000..6391d60021e --- /dev/null +++ b/src/WatchPrototype/DotNetDeltaApplier/Microsoft.Extensions.DotNetDeltaApplier.csproj @@ -0,0 +1,28 @@ + + + + net6.0;net10.0 + MicrosoftAspNetCore + + true + + + + + + + + + + + + + + + + diff --git a/src/WatchPrototype/DotNetDeltaApplier/Properties/AssemblyInfo.cs b/src/WatchPrototype/DotNetDeltaApplier/Properties/AssemblyInfo.cs new file mode 100644 index 00000000000..ca99145439c --- /dev/null +++ b/src/WatchPrototype/DotNetDeltaApplier/Properties/AssemblyInfo.cs @@ -0,0 +1,6 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Microsoft.Extensions.DotNetDeltaApplier.Tests, PublicKey = 0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] diff --git a/src/WatchPrototype/HotReloadAgent.Data/.editorconfig b/src/WatchPrototype/HotReloadAgent.Data/.editorconfig new file mode 100644 index 00000000000..4694508c4da --- /dev/null +++ b/src/WatchPrototype/HotReloadAgent.Data/.editorconfig @@ -0,0 +1,6 @@ +[*.cs] + +# IDE0240: Remove redundant nullable directive +# The directive needs to be included since all sources in a source package are considered generated code +# when referenced from a project via package reference. +dotnet_diagnostic.IDE0240.severity = none \ No newline at end of file diff --git a/src/WatchPrototype/HotReloadAgent.Data/AgentEnvironmentVariables.cs b/src/WatchPrototype/HotReloadAgent.Data/AgentEnvironmentVariables.cs new file mode 100644 index 00000000000..2bcec8b91ff --- /dev/null +++ b/src/WatchPrototype/HotReloadAgent.Data/AgentEnvironmentVariables.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. + +#nullable enable + +namespace Microsoft.DotNet.HotReload; + +internal static class AgentEnvironmentVariables +{ + /// + /// Intentionally different from the variable name used by the debugger. + /// This is to avoid the debugger colliding with dotnet-watch pipe connection when debugging dotnet-watch (or tests). + /// + public const string DotNetWatchHotReloadNamedPipeName = "DOTNET_WATCH_HOTRELOAD_NAMEDPIPE_NAME"; + + /// + /// Enables logging from the client delta applier agent. + /// + public const string HotReloadDeltaClientLogMessages = "HOTRELOAD_DELTA_CLIENT_LOG_MESSAGES"; + + /// + /// dotnet runtime environment variable. + /// https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-environment-variables#dotnet_startup_hooks + /// + public const string DotNetStartupHooks = "DOTNET_STARTUP_HOOKS"; + + /// + /// dotnet runtime environment variable. + /// + public const string DotNetModifiableAssemblies = "DOTNET_MODIFIABLE_ASSEMBLIES"; +} diff --git a/src/WatchPrototype/HotReloadAgent.Data/AgentMessageSeverity.cs b/src/WatchPrototype/HotReloadAgent.Data/AgentMessageSeverity.cs new file mode 100644 index 00000000000..1f00b70330f --- /dev/null +++ b/src/WatchPrototype/HotReloadAgent.Data/AgentMessageSeverity.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. + +#nullable enable + +namespace Microsoft.DotNet.HotReload; + +internal enum AgentMessageSeverity : byte +{ + Verbose = 0, + Warning = 1, + Error = 2, +} diff --git a/src/WatchPrototype/HotReloadAgent.Data/Microsoft.DotNet.HotReload.Agent.Data.Package.csproj b/src/WatchPrototype/HotReloadAgent.Data/Microsoft.DotNet.HotReload.Agent.Data.Package.csproj new file mode 100644 index 00000000000..4dde206b752 --- /dev/null +++ b/src/WatchPrototype/HotReloadAgent.Data/Microsoft.DotNet.HotReload.Agent.Data.Package.csproj @@ -0,0 +1,29 @@ + + + + netstandard2.0 + false + none + false + preview + + + true + true + true + Microsoft.DotNet.HotReload.Agent.Data + false + + Package containing sources of Hot Reload agent data types. + + + $(NoWarn);NU5128 + + + + + + + diff --git a/src/WatchPrototype/HotReloadAgent.Data/Microsoft.DotNet.HotReload.Agent.Data.projitems b/src/WatchPrototype/HotReloadAgent.Data/Microsoft.DotNet.HotReload.Agent.Data.projitems new file mode 100644 index 00000000000..48476635fae --- /dev/null +++ b/src/WatchPrototype/HotReloadAgent.Data/Microsoft.DotNet.HotReload.Agent.Data.projitems @@ -0,0 +1,14 @@ + + + + $(MSBuildAllProjects);$(MSBuildThisFileFullPath) + true + {0762B436-F4B0-4008-9097-BB5FF6BD84AF} + + + Microsoft.DotNet.HotReload + + + + + \ No newline at end of file diff --git a/src/WatchPrototype/HotReloadAgent.Data/Microsoft.DotNet.HotReload.Agent.Data.shproj b/src/WatchPrototype/HotReloadAgent.Data/Microsoft.DotNet.HotReload.Agent.Data.shproj new file mode 100644 index 00000000000..2668cf53de0 --- /dev/null +++ b/src/WatchPrototype/HotReloadAgent.Data/Microsoft.DotNet.HotReload.Agent.Data.shproj @@ -0,0 +1,13 @@ + + + + 0762B436-F4B0-4008-9097-BB5FF6BD84AF + 14.0 + + + + + + + + \ No newline at end of file diff --git a/src/WatchPrototype/HotReloadAgent.Data/ResponseLoggingLevel.cs b/src/WatchPrototype/HotReloadAgent.Data/ResponseLoggingLevel.cs new file mode 100644 index 00000000000..211d77370b7 --- /dev/null +++ b/src/WatchPrototype/HotReloadAgent.Data/ResponseLoggingLevel.cs @@ -0,0 +1,12 @@ +// 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 enum ResponseLoggingLevel : byte +{ + WarningsAndErrors = 0, + Verbose = 1, +} diff --git a/src/WatchPrototype/HotReloadAgent.Data/RuntimeManagedCodeUpdate.cs b/src/WatchPrototype/HotReloadAgent.Data/RuntimeManagedCodeUpdate.cs new file mode 100644 index 00000000000..1e56f9dd82d --- /dev/null +++ b/src/WatchPrototype/HotReloadAgent.Data/RuntimeManagedCodeUpdate.cs @@ -0,0 +1,17 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable enable + +using System; + +namespace Microsoft.DotNet.HotReload; + +internal readonly struct RuntimeManagedCodeUpdate(Guid moduleId, byte[] metadataDelta, byte[] ilDelta, byte[] pdbDelta, int[] updatedTypes) +{ + public Guid ModuleId { get; } = moduleId; + public byte[] MetadataDelta { get; } = metadataDelta; + public byte[] ILDelta { get; } = ilDelta; + public byte[] PdbDelta { get; } = pdbDelta; + public int[] UpdatedTypes { get; } = updatedTypes; +} diff --git a/src/WatchPrototype/HotReloadAgent.Data/RuntimeStaticAssetUpdate.cs b/src/WatchPrototype/HotReloadAgent.Data/RuntimeStaticAssetUpdate.cs new file mode 100644 index 00000000000..57cfc2535f8 --- /dev/null +++ b/src/WatchPrototype/HotReloadAgent.Data/RuntimeStaticAssetUpdate.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 RuntimeStaticAssetUpdate(string assemblyName, string relativePath, byte[] contents, bool isApplicationProject) +{ + public string AssemblyName { get; } = assemblyName; + public bool IsApplicationProject { get; } = isApplicationProject; + public string RelativePath { get; } = relativePath; + public byte[] Contents { get; } = contents; +} diff --git a/src/WatchPrototype/HotReloadAgent.Host/.editorconfig b/src/WatchPrototype/HotReloadAgent.Host/.editorconfig new file mode 100644 index 00000000000..4694508c4da --- /dev/null +++ b/src/WatchPrototype/HotReloadAgent.Host/.editorconfig @@ -0,0 +1,6 @@ +[*.cs] + +# IDE0240: Remove redundant nullable directive +# The directive needs to be included since all sources in a source package are considered generated code +# when referenced from a project via package reference. +dotnet_diagnostic.IDE0240.severity = none \ No newline at end of file diff --git a/src/WatchPrototype/HotReloadAgent.Host/Microsoft.DotNet.HotReload.Agent.Host.Package.csproj b/src/WatchPrototype/HotReloadAgent.Host/Microsoft.DotNet.HotReload.Agent.Host.Package.csproj new file mode 100644 index 00000000000..b5630dcc8e6 --- /dev/null +++ b/src/WatchPrototype/HotReloadAgent.Host/Microsoft.DotNet.HotReload.Agent.Host.Package.csproj @@ -0,0 +1,42 @@ + + + + + net6.0;net10.0 + true + + false + none + false + preview + + + true + true + true + Microsoft.DotNet.HotReload.Agent.Host + false + Package containing Hot Reload agent host. + + $(NoWarn);NU5128 + + + + + + + + + + + + + + + + diff --git a/src/WatchPrototype/HotReloadAgent.Host/Microsoft.DotNet.HotReload.Agent.Host.projitems b/src/WatchPrototype/HotReloadAgent.Host/Microsoft.DotNet.HotReload.Agent.Host.projitems new file mode 100644 index 00000000000..c4cd6379e47 --- /dev/null +++ b/src/WatchPrototype/HotReloadAgent.Host/Microsoft.DotNet.HotReload.Agent.Host.projitems @@ -0,0 +1,14 @@ + + + + $(MSBuildAllProjects);$(MSBuildThisFileFullPath) + true + 15C936B2-901D-4A27-AFA6-89CF56F5EB20 + + + Microsoft.DotNet.HotReload + + + + + \ No newline at end of file diff --git a/src/WatchPrototype/HotReloadAgent.Host/Microsoft.DotNet.HotReload.Agent.Host.shproj b/src/WatchPrototype/HotReloadAgent.Host/Microsoft.DotNet.HotReload.Agent.Host.shproj new file mode 100644 index 00000000000..771b511f6b4 --- /dev/null +++ b/src/WatchPrototype/HotReloadAgent.Host/Microsoft.DotNet.HotReload.Agent.Host.shproj @@ -0,0 +1,13 @@ + + + + 15C936B2-901D-4A27-AFA6-89CF56F5EB20 + 14.0 + + + + + + + + \ No newline at end of file diff --git a/src/WatchPrototype/HotReloadAgent.Host/PipeListener.cs b/src/WatchPrototype/HotReloadAgent.Host/PipeListener.cs new file mode 100644 index 00000000000..dfa108189df --- /dev/null +++ b/src/WatchPrototype/HotReloadAgent.Host/PipeListener.cs @@ -0,0 +1,196 @@ +// 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.Diagnostics; +using System.IO.Pipes; +using System.Reflection; +using System.Runtime.Loader; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.DotNet.HotReload; + +internal sealed class PipeListener(string pipeName, IHotReloadAgent agent, Action log, int connectionTimeoutMS = 5000) +{ + /// + /// Messages to the client sent after the initial is sent + /// need to be sent while holding this lock in order to synchronize + /// 1) responses to requests received from the client (e.g. ) or + /// 2) notifications sent to the client that may be triggered at arbitrary times (e.g. ). + /// + private readonly SemaphoreSlim _messageToClientLock = new(initialCount: 1); + + // Not-null once initialized: + private NamedPipeClientStream? _pipeClient; + + public Task Listen(CancellationToken cancellationToken) + { + // Connect to the pipe synchronously. + // + // If a debugger is attached and there is a breakpoint in the startup code connecting asynchronously would + // set up a race between this code connecting to the server, and the breakpoint being hit. If the breakpoint + // hits first, applying changes will throw an error that the client is not connected. + // + // 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; + } + + try + { + // block execution of the app until initial updates are applied: + InitializeAsync(cancellationToken).GetAwaiter().GetResult(); + } + catch (Exception e) + { + if (e is not OperationCanceledException) + { + log(e.Message); + } + + _pipeClient.Dispose(); + agent.Dispose(); + + return Task.CompletedTask; + } + + return Task.Run(async () => + { + try + { + await ReceiveAndApplyUpdatesAsync(initialUpdates: false, cancellationToken); + } + catch (Exception e) when (e is not OperationCanceledException) + { + log(e.Message); + } + finally + { + _pipeClient.Dispose(); + agent.Dispose(); + } + }, 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); + + // Apply updates made before this process was launched to avoid executing unupdated versions of the affected modules. + + // We should only receive ManagedCodeUpdate when when the debugger isn't attached, + // otherwise the initialization should send InitialUpdatesCompleted immediately. + // The debugger itself applies these updates when launching process with the debugger attached. + await ReceiveAndApplyUpdatesAsync(initialUpdates: true, cancellationToken); + } + + private async Task ReceiveAndApplyUpdatesAsync(bool initialUpdates, CancellationToken cancellationToken) + { + Debug.Assert(_pipeClient != null); + + while (_pipeClient.IsConnected) + { + var payloadType = (RequestType)await _pipeClient.ReadByteAsync(cancellationToken); + switch (payloadType) + { + case RequestType.ManagedCodeUpdate: + await ReadAndApplyManagedCodeUpdateAsync(cancellationToken); + break; + + case RequestType.StaticAssetUpdate: + await ReadAndApplyStaticAssetUpdateAsync(cancellationToken); + break; + + case RequestType.InitialUpdatesCompleted when initialUpdates: + return; + + default: + // can't continue, the pipe content is in an unknown state + throw new InvalidOperationException($"Unexpected payload type: {payloadType}"); + } + } + } + + private async ValueTask ReadAndApplyManagedCodeUpdateAsync(CancellationToken cancellationToken) + { + Debug.Assert(_pipeClient != null); + + var request = await ManagedCodeUpdateRequest.ReadAsync(_pipeClient, cancellationToken); + + bool success; + try + { + agent.ApplyManagedCodeUpdates(request.Updates); + success = true; + } + catch (Exception e) + { + agent.Reporter.Report($"The runtime failed to applying the change: {e.Message}", AgentMessageSeverity.Error); + agent.Reporter.Report("Further changes won't be applied to this process.", AgentMessageSeverity.Warning); + success = false; + } + + var logEntries = agent.Reporter.GetAndClearLogEntries(request.ResponseLoggingLevel); + + await SendResponseAsync(new UpdateResponse(logEntries, success), cancellationToken); + } + + private async ValueTask ReadAndApplyStaticAssetUpdateAsync(CancellationToken cancellationToken) + { + Debug.Assert(_pipeClient != null); + + var request = await StaticAssetUpdateRequest.ReadAsync(_pipeClient, cancellationToken); + + try + { + agent.ApplyStaticAssetUpdate(request.Update); + } + catch (Exception e) + { + agent.Reporter.Report($"Failed to apply static asset update: {e.Message}", AgentMessageSeverity.Error); + } + + var logEntries = agent.Reporter.GetAndClearLogEntries(request.ResponseLoggingLevel); + + // Updating static asset only invokes ContentUpdate metadata update handlers. + // Failures of these handlers are reported to the log and ignored. + // Therefore, this request always succeeds. + await SendResponseAsync(new UpdateResponse(logEntries, success: true), cancellationToken); + } + + 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); + } + finally + { + _messageToClientLock.Release(); + } + } +} diff --git a/src/WatchPrototype/HotReloadAgent.Host/StartupHook.cs b/src/WatchPrototype/HotReloadAgent.Host/StartupHook.cs new file mode 100644 index 00000000000..3ad6762d2d1 --- /dev/null +++ b/src/WatchPrototype/HotReloadAgent.Host/StartupHook.cs @@ -0,0 +1,181 @@ +// 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.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; +using System.Threading.Tasks; +using Microsoft.DotNet.HotReload; + +/// +/// The runtime startup hook looks for top-level type named "StartupHook". +/// +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() + && !OperatingSystem.IsBrowser(); + private static readonly bool s_supportsPosixSignals = s_supportsConsoleColor; + +#if NET10_0_OR_GREATER + private static PosixSignalRegistration? s_signalRegistration; +#endif + + /// + /// Invoked by the runtime when the containing assembly is listed in DOTNET_STARTUP_HOOKS. + /// + public static void Initialize() + { + var processPath = Environment.GetCommandLineArgs().FirstOrDefault(); + var processDir = Path.GetDirectoryName(processPath)!; + + Log($"Loaded into process: {processPath} ({typeof(StartupHook).Assembly.Location})"); + + HotReloadAgent.ClearHotReloadEnvironmentVariables(typeof(StartupHook)); + + if (string.IsNullOrEmpty(s_namedPipeName)) + { + Log($"Environment variable {AgentEnvironmentVariables.DotNetWatchHotReloadNamedPipeName} has no value"); + return; + } + + RegisterSignalHandlers(); + + PipeListener? listener = null; + + var agent = new HotReloadAgent( + assemblyResolvingHandler: (_, args) => + { + Log($"Resolving '{args.Name}, Version={args.Version}'"); + var path = Path.Combine(processDir, args.Name + ".dll"); + return File.Exists(path) ? AssemblyLoadContext.Default.LoadFromAssemblyPath(path) : null; + }, + hotReloadExceptionCreateHandler: (code, message) => + { + // Continue executing the code if the debugger is attached. + // It will throw the exception and the debugger will handle it. + if (Debugger.IsAttached) + { + return; + } + + Debug.Assert(listener != null); + Log($"Runtime rude edit detected: '{message}'"); + + SendAndForgetAsync().Wait(); + + // Handle Ctrl+C to terminate gracefully: + Console.CancelKeyPress += (_, _) => Environment.Exit(0); + + // wait for the process to be terminated by the Hot Reload client (other threads might still execute): + Thread.Sleep(Timeout.Infinite); + + async Task SendAndForgetAsync() + { + try + { + await listener.SendResponseAsync(new HotReloadExceptionCreatedNotification(code, message), CancellationToken.None); + } + catch + { + // do not crash the app + } + } + }); + + listener = new PipeListener(s_namedPipeName, agent, Log); + + // fire and forget: + _ = listener.Listen(CancellationToken.None); + } + + private static void RegisterSignalHandlers() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + // Enables handling of Ctrl+C in a process where it was disabled. + // + // If a process is launched with CREATE_NEW_PROCESS_GROUP flag + // it allows the parent process to send Ctrl+C event to the child process, + // but also disables Ctrl+C handlers. + // + // "If the HandlerRoutine parameter is NULL, a TRUE value causes the calling process to ignore CTRL+C input, + // and a FALSE value restores normal processing of CTRL+C input. + // This attribute of ignoring or processing CTRL+C is inherited by child processes." + + if (SetConsoleCtrlHandler(null, false)) + { + Log("Windows Ctrl+C handling enabled."); + } + else + { + Log($"Failed to enable Ctrl+C handling: {GetLastPInvokeErrorMessage()}"); + } + + [DllImport("kernel32.dll", SetLastError = true)] + static extern bool SetConsoleCtrlHandler(Delegate? handler, bool add); + } + else if (s_supportsPosixSignals) + { +#if NET10_0_OR_GREATER + // Register a handler for SIGTERM to allow graceful shutdown of the application on Unix. + // See https://github.com/dotnet/docs/issues/46226. + + // Note: registered handlers are executed in reverse order of their registration. + // Since the startup hook is executed before any code of the application, it is the first handler registered and thus the last to run. + + s_signalRegistration = PosixSignalRegistration.Create(PosixSignal.SIGTERM, context => + { + Log($"SIGTERM received. Cancel={context.Cancel}"); + + if (!context.Cancel) + { + Environment.Exit(0); + } + }); + + Log("Posix signal handlers registered."); +#endif + } + } + + private static string GetLastPInvokeErrorMessage() + { + var error = Marshal.GetLastPInvokeError(); +#if NET10_0_OR_GREATER + return $"{Marshal.GetPInvokeErrorMessage(error)} (code {error})"; +#else + return $"error code {error}"; +#endif + } + + private static void Log(string message) + { + var prefix = s_standardOutputLogPrefix; + if (!string.IsNullOrEmpty(prefix)) + { + if (s_supportsConsoleColor) + { + Console.ForegroundColor = ConsoleColor.DarkGray; + } + + Console.Error.WriteLine($"{prefix} {message}"); + + if (s_supportsConsoleColor) + { + Console.ResetColor(); + } + } + } +} diff --git a/src/WatchPrototype/HotReloadAgent.PipeRpc/.editorconfig b/src/WatchPrototype/HotReloadAgent.PipeRpc/.editorconfig new file mode 100644 index 00000000000..4694508c4da --- /dev/null +++ b/src/WatchPrototype/HotReloadAgent.PipeRpc/.editorconfig @@ -0,0 +1,6 @@ +[*.cs] + +# IDE0240: Remove redundant nullable directive +# The directive needs to be included since all sources in a source package are considered generated code +# when referenced from a project via package reference. +dotnet_diagnostic.IDE0240.severity = none \ No newline at end of file diff --git a/src/WatchPrototype/HotReloadAgent.PipeRpc/Microsoft.DotNet.HotReload.Agent.PipeRpc.Package.csproj b/src/WatchPrototype/HotReloadAgent.PipeRpc/Microsoft.DotNet.HotReload.Agent.PipeRpc.Package.csproj new file mode 100644 index 00000000000..def624accfd --- /dev/null +++ b/src/WatchPrototype/HotReloadAgent.PipeRpc/Microsoft.DotNet.HotReload.Agent.PipeRpc.Package.csproj @@ -0,0 +1,41 @@ + + + + netstandard2.0 + false + none + false + preview + + + true + true + true + Microsoft.DotNet.HotReload.Agent.PipeRpc + false + + Package containing sources of Hot Reload agent pipe RPC. + + + $(NoWarn);NU5128 + + + + + + + + + + + + + + + + diff --git a/src/WatchPrototype/HotReloadAgent.PipeRpc/Microsoft.DotNet.HotReload.Agent.PipeRpc.projitems b/src/WatchPrototype/HotReloadAgent.PipeRpc/Microsoft.DotNet.HotReload.Agent.PipeRpc.projitems new file mode 100644 index 00000000000..f1969e528c5 --- /dev/null +++ b/src/WatchPrototype/HotReloadAgent.PipeRpc/Microsoft.DotNet.HotReload.Agent.PipeRpc.projitems @@ -0,0 +1,14 @@ + + + + $(MSBuildAllProjects);$(MSBuildThisFileFullPath) + true + {FA3C7F91-42A2-45AD-897C-F646B081016C} + + + Microsoft.DotNet.HotReload + + + + + \ No newline at end of file diff --git a/src/WatchPrototype/HotReloadAgent.PipeRpc/Microsoft.DotNet.HotReload.Agent.PipeRpc.shproj b/src/WatchPrototype/HotReloadAgent.PipeRpc/Microsoft.DotNet.HotReload.Agent.PipeRpc.shproj new file mode 100644 index 00000000000..cf55004689c --- /dev/null +++ b/src/WatchPrototype/HotReloadAgent.PipeRpc/Microsoft.DotNet.HotReload.Agent.PipeRpc.shproj @@ -0,0 +1,13 @@ + + + + FA3C7F91-42A2-45AD-897C-F646B081016C + 14.0 + + + + + + + + \ No newline at end of file diff --git a/src/WatchPrototype/HotReloadAgent.PipeRpc/NamedPipeContract.cs b/src/WatchPrototype/HotReloadAgent.PipeRpc/NamedPipeContract.cs new file mode 100644 index 00000000000..dfa0158c53c --- /dev/null +++ b/src/WatchPrototype/HotReloadAgent.PipeRpc/NamedPipeContract.cs @@ -0,0 +1,227 @@ +// 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.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.DotNet.HotReload; + +internal interface IMessage +{ + ValueTask WriteAsync(Stream stream, CancellationToken cancellationToken); +} + +internal interface IRequest : IMessage +{ + RequestType Type { get; } +} + +internal interface IResponse : IMessage +{ + ResponseType Type { get; } +} + +internal interface IUpdateRequest : IRequest +{ +} + +internal enum RequestType : byte +{ + ManagedCodeUpdate = 1, + StaticAssetUpdate = 2, + InitialUpdatesCompleted = 3, +} + +internal enum ResponseType : byte +{ + InitializationResponse = 1, + UpdateResponse = 2, + HotReloadExceptionNotification = 3, +} + +internal readonly struct ManagedCodeUpdateRequest(IReadOnlyList updates, ResponseLoggingLevel responseLoggingLevel) : IUpdateRequest +{ + private const byte Version = 4; + + public IReadOnlyList Updates { get; } = updates; + public ResponseLoggingLevel ResponseLoggingLevel { get; } = responseLoggingLevel; + public RequestType Type => RequestType.ManagedCodeUpdate; + + public async ValueTask WriteAsync(Stream stream, CancellationToken cancellationToken) + { + await stream.WriteAsync(Version, cancellationToken); + await stream.WriteAsync(Updates.Count, cancellationToken); + + foreach (var update in Updates) + { + await stream.WriteAsync(update.ModuleId, cancellationToken); + await stream.WriteByteArrayAsync(update.MetadataDelta, cancellationToken); + await stream.WriteByteArrayAsync(update.ILDelta, cancellationToken); + await stream.WriteByteArrayAsync(update.PdbDelta, cancellationToken); + await stream.WriteAsync(update.UpdatedTypes, cancellationToken); + } + + await stream.WriteAsync((byte)ResponseLoggingLevel, cancellationToken); + } + + public static async ValueTask ReadAsync(Stream stream, CancellationToken cancellationToken) + { + var version = await stream.ReadByteAsync(cancellationToken); + if (version != Version) + { + throw new NotSupportedException($"Unsupported version {version}."); + } + + var count = await stream.ReadInt32Async(cancellationToken); + + var updates = new RuntimeManagedCodeUpdate[count]; + for (var i = 0; i < count; i++) + { + var moduleId = await stream.ReadGuidAsync(cancellationToken); + var metadataDelta = await stream.ReadByteArrayAsync(cancellationToken); + var ilDelta = await stream.ReadByteArrayAsync(cancellationToken); + var pdbDelta = await stream.ReadByteArrayAsync(cancellationToken); + var updatedTypes = await stream.ReadIntArrayAsync(cancellationToken); + + updates[i] = new RuntimeManagedCodeUpdate(moduleId, metadataDelta: metadataDelta, ilDelta: ilDelta, pdbDelta: pdbDelta, updatedTypes); + } + + var responseLoggingLevel = (ResponseLoggingLevel)await stream.ReadByteAsync(cancellationToken); + return new ManagedCodeUpdateRequest(updates, responseLoggingLevel: responseLoggingLevel); + } +} + +internal readonly struct UpdateResponse(IReadOnlyCollection<(string message, AgentMessageSeverity severity)> log, bool success) : IResponse +{ + public ResponseType Type => ResponseType.UpdateResponse; + + public async ValueTask WriteAsync(Stream stream, CancellationToken cancellationToken) + { + await stream.WriteAsync(success, cancellationToken); + await stream.WriteAsync(log.Count, cancellationToken); + + foreach (var (message, severity) in log) + { + await stream.WriteAsync(message, cancellationToken); + await stream.WriteAsync((byte)severity, cancellationToken); + } + } + + public static async ValueTask<(bool success, IAsyncEnumerable<(string message, AgentMessageSeverity severity)>)> ReadAsync( + Stream stream, CancellationToken cancellationToken) + { + var success = await stream.ReadBooleanAsync(cancellationToken); + var log = ReadLogAsync(cancellationToken); + return (success, log); + + async IAsyncEnumerable<(string message, AgentMessageSeverity severity)> ReadLogAsync([EnumeratorCancellation] CancellationToken cancellationToken) + { + var entryCount = await stream.ReadInt32Async(cancellationToken); + + for (var i = 0; i < entryCount; i++) + { + var message = await stream.ReadStringAsync(cancellationToken); + var severity = (AgentMessageSeverity)await stream.ReadByteAsync(cancellationToken); + yield return (message, severity); + } + } + } +} + +internal readonly struct ClientInitializationResponse(string capabilities) : IResponse +{ + private const byte Version = 0; + + public ResponseType Type => ResponseType.InitializationResponse; + + public string Capabilities { get; } = capabilities; + + public async ValueTask WriteAsync(Stream stream, CancellationToken cancellationToken) + { + await stream.WriteAsync(Version, cancellationToken); + await stream.WriteAsync(Capabilities, cancellationToken); + } + + public static async ValueTask ReadAsync(Stream stream, CancellationToken cancellationToken) + { + var version = await stream.ReadByteAsync(cancellationToken); + if (version != Version) + { + throw new NotSupportedException($"Unsupported version {version}."); + } + + var capabilities = await stream.ReadStringAsync(cancellationToken); + return new ClientInitializationResponse(capabilities); + } +} + +internal readonly struct HotReloadExceptionCreatedNotification(int code, string message) : IResponse +{ + public ResponseType Type => ResponseType.HotReloadExceptionNotification; + public int Code => code; + public string Message => message; + + public async ValueTask WriteAsync(Stream stream, CancellationToken cancellationToken) + { + await stream.WriteAsync(code, cancellationToken); + await stream.WriteAsync(message, cancellationToken); + } + + public static async ValueTask ReadAsync(Stream stream, CancellationToken cancellationToken) + { + var code = await stream.ReadInt32Async(cancellationToken); + var message = await stream.ReadStringAsync(cancellationToken); + return new HotReloadExceptionCreatedNotification(code, message); + } +} + +internal readonly struct StaticAssetUpdateRequest( + RuntimeStaticAssetUpdate update, + ResponseLoggingLevel responseLoggingLevel) : IUpdateRequest +{ + private const byte Version = 2; + + public RuntimeStaticAssetUpdate Update { get; } = update; + public ResponseLoggingLevel ResponseLoggingLevel { get; } = responseLoggingLevel; + + public RequestType Type => RequestType.StaticAssetUpdate; + + public async ValueTask WriteAsync(Stream stream, CancellationToken cancellationToken) + { + await stream.WriteAsync(Version, cancellationToken); + await stream.WriteAsync(Update.AssemblyName, cancellationToken); + await stream.WriteAsync(Update.IsApplicationProject, cancellationToken); + await stream.WriteAsync(Update.RelativePath, cancellationToken); + await stream.WriteByteArrayAsync(Update.Contents, cancellationToken); + await stream.WriteAsync((byte)ResponseLoggingLevel, cancellationToken); + } + + public static async ValueTask ReadAsync(Stream stream, CancellationToken cancellationToken) + { + var version = await stream.ReadByteAsync(cancellationToken); + if (version != Version) + { + throw new NotSupportedException($"Unsupported version {version}."); + } + + var assemblyName = await stream.ReadStringAsync(cancellationToken); + var isApplicationProject = await stream.ReadBooleanAsync(cancellationToken); + var relativePath = await stream.ReadStringAsync(cancellationToken); + var contents = await stream.ReadByteArrayAsync(cancellationToken); + var responseLoggingLevel = (ResponseLoggingLevel)await stream.ReadByteAsync(cancellationToken); + + return new StaticAssetUpdateRequest( + new RuntimeStaticAssetUpdate( + assemblyName: assemblyName, + relativePath: relativePath, + contents: contents, + isApplicationProject), + responseLoggingLevel); + } +} diff --git a/src/WatchPrototype/HotReloadAgent.PipeRpc/StreamExtensions.cs b/src/WatchPrototype/HotReloadAgent.PipeRpc/StreamExtensions.cs new file mode 100644 index 00000000000..369f5ef72c1 --- /dev/null +++ b/src/WatchPrototype/HotReloadAgent.PipeRpc/StreamExtensions.cs @@ -0,0 +1,276 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. + +#nullable enable + +using System; +using System.Buffers; +using System.Buffers.Binary; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.DotNet.HotReload; + +/// +/// Implements async read/write helpers that provide functionality of and . +/// See https://github.com/dotnet/runtime/issues/17229 +/// +internal static class StreamExtesions +{ + public static ValueTask WriteAsync(this Stream stream, bool value, CancellationToken cancellationToken) + => WriteAsync(stream, (byte)(value ? 1 : 0), cancellationToken); + + public static async ValueTask WriteAsync(this Stream stream, byte value, CancellationToken cancellationToken) + { + var size = sizeof(byte); + var buffer = ArrayPool.Shared.Rent(minimumLength: size); + try + { + buffer[0] = value; + await stream.WriteAsync(buffer, offset: 0, count: size, cancellationToken); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + public static async ValueTask WriteAsync(this Stream stream, int value, CancellationToken cancellationToken) + { + var size = sizeof(int); + var buffer = ArrayPool.Shared.Rent(minimumLength: size); + try + { + BinaryPrimitives.WriteInt32LittleEndian(buffer, value); + await stream.WriteAsync(buffer, offset: 0, count: size, cancellationToken); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + public static ValueTask WriteAsync(this Stream stream, Guid value, CancellationToken cancellationToken) + => stream.WriteAsync(value.ToByteArray(), cancellationToken); + + public static async ValueTask WriteByteArrayAsync(this Stream stream, byte[] value, CancellationToken cancellationToken) + { + await stream.WriteAsync(value.Length, cancellationToken); + await stream.WriteAsync(value, cancellationToken); + } + + public static async ValueTask WriteAsync(this Stream stream, int[] value, CancellationToken cancellationToken) + { + var size = sizeof(int) * (value.Length + 1); + var buffer = ArrayPool.Shared.Rent(minimumLength: size); + try + { + BinaryPrimitives.WriteInt32LittleEndian(buffer, value.Length); + for (int i = 0; i < value.Length; i++) + { + BinaryPrimitives.WriteInt32LittleEndian(buffer.AsSpan((i + 1) * sizeof(int), sizeof(int)), value[i]); + } + + await stream.WriteAsync(buffer, offset: 0, count: size, cancellationToken); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + public static async ValueTask WriteAsync(this Stream stream, string value, CancellationToken cancellationToken) + { + var bytes = Encoding.UTF8.GetBytes(value); + await stream.Write7BitEncodedIntAsync(bytes.Length, cancellationToken); + await stream.WriteAsync(bytes, cancellationToken); + } + +#if !NET + public static async ValueTask WriteAsync(this Stream stream, byte[] value, CancellationToken cancellationToken) + => await stream.WriteAsync(value, offset: 0, count: value.Length, cancellationToken); +#endif + public static async ValueTask Write7BitEncodedIntAsync(this Stream stream, int value, CancellationToken cancellationToken) + { + uint uValue = (uint)value; + + while (uValue > 0x7Fu) + { + await stream.WriteAsync((byte)(uValue | ~0x7Fu), cancellationToken); + uValue >>= 7; + } + + await stream.WriteAsync((byte)uValue, cancellationToken); + } + + public static async ValueTask ReadBooleanAsync(this Stream stream, CancellationToken cancellationToken) + => await stream.ReadByteAsync(cancellationToken) != 0; + + public static async ValueTask ReadByteAsync(this Stream stream, CancellationToken cancellationToken) + { + int size = sizeof(byte); + var buffer = ArrayPool.Shared.Rent(minimumLength: size); + try + { + await ReadExactlyAsync(stream, buffer, size, cancellationToken); + return buffer[0]; + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + public static async ValueTask ReadInt32Async(this Stream stream, CancellationToken cancellationToken) + { + int size = sizeof(int); + var buffer = ArrayPool.Shared.Rent(minimumLength: size); + try + { + await ReadExactlyAsync(stream, buffer, size, cancellationToken); + return BinaryPrimitives.ReadInt32LittleEndian(buffer); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + public static async ValueTask ReadGuidAsync(this Stream stream, CancellationToken cancellationToken) + { + const int size = 16; +#if NET + var buffer = ArrayPool.Shared.Rent(minimumLength: size); + + try + { + await ReadExactlyAsync(stream, buffer, size, cancellationToken); + return new Guid(buffer.AsSpan(0, size)); + } + finally + { + ArrayPool.Shared.Return(buffer); + } +#else + var buffer = new byte[size]; + await ReadExactlyAsync(stream, buffer, size, cancellationToken); + return new Guid(buffer); +#endif + } + + public static async ValueTask ReadByteArrayAsync(this Stream stream, CancellationToken cancellationToken) + { + var count = await stream.ReadInt32Async(cancellationToken); + if (count == 0) + { + return []; + } + + var bytes = new byte[count]; + await ReadExactlyAsync(stream, bytes, count, cancellationToken); + return bytes; + } + + public static async ValueTask ReadIntArrayAsync(this Stream stream, CancellationToken cancellationToken) + { + var count = await stream.ReadInt32Async(cancellationToken); + if (count == 0) + { + return []; + } + + var result = new int[count]; + int size = count * sizeof(int); + var buffer = ArrayPool.Shared.Rent(minimumLength: size); + try + { + await ReadExactlyAsync(stream, buffer, size, cancellationToken); + + for (var i = 0; i < count; i++) + { + result[i] = BinaryPrimitives.ReadInt32LittleEndian(buffer.AsSpan(i * sizeof(int))); + } + } + finally + { + ArrayPool.Shared.Return(buffer); + } + + return result; + } + + public static async ValueTask ReadStringAsync(this Stream stream, CancellationToken cancellationToken) + { + int size = await stream.Read7BitEncodedIntAsync(cancellationToken); + if (size < 0) + { + throw new InvalidDataException(); + } + + if (size == 0) + { + return string.Empty; + } + + var buffer = ArrayPool.Shared.Rent(minimumLength: size); + try + { + await ReadExactlyAsync(stream, buffer, size, cancellationToken); + return Encoding.UTF8.GetString(buffer, 0, size); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + public static async ValueTask Read7BitEncodedIntAsync(this Stream stream, CancellationToken cancellationToken) + { + const int MaxBytesWithoutOverflow = 4; + + uint result = 0; + byte b; + + for (int shift = 0; shift < MaxBytesWithoutOverflow * 7; shift += 7) + { + b = await stream.ReadByteAsync(cancellationToken); + result |= (b & 0x7Fu) << shift; + + if (b <= 0x7Fu) + { + return (int)result; + } + } + + // Read the 5th byte. Since we already read 28 bits, + // the value of this byte must fit within 4 bits (32 - 28), + // and it must not have the high bit set. + + b = await stream.ReadByteAsync(cancellationToken); + if (b > 0b_1111u) + { + throw new InvalidDataException(); + } + + result |= (uint)b << (MaxBytesWithoutOverflow * 7); + return (int)result; + } + + private static async ValueTask ReadExactlyAsync(this Stream stream, byte[] buffer, int size, CancellationToken cancellationToken) + { + int totalRead = 0; + while (totalRead < size) + { + int read = await stream.ReadAsync(buffer, offset: totalRead, count: size - totalRead, cancellationToken).ConfigureAwait(false); + if (read == 0) + { + throw new EndOfStreamException(); + } + + totalRead += read; + } + + return totalRead; + } +} 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 new file mode 100644 index 00000000000..1cad23202de --- /dev/null +++ b/src/WatchPrototype/HotReloadAgent.WebAssembly.Browser/Microsoft.DotNet.HotReload.WebAssembly.Browser.csproj @@ -0,0 +1,26 @@ + + + + $(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 new file mode 100644 index 00000000000..1aad8e47f07 --- /dev/null +++ b/src/WatchPrototype/HotReloadAgent.WebAssembly.Browser/WebAssemblyHotReload.cs @@ -0,0 +1,179 @@ +// 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 new file mode 100644 index 00000000000..54e496f0153 --- /dev/null +++ b/src/WatchPrototype/HotReloadAgent.WebAssembly.Browser/wwwroot/Microsoft.DotNet.HotReload.WebAssembly.Browser.lib.module.js @@ -0,0 +1,44 @@ +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/HotReloadAgent/.editorconfig b/src/WatchPrototype/HotReloadAgent/.editorconfig new file mode 100644 index 00000000000..4694508c4da --- /dev/null +++ b/src/WatchPrototype/HotReloadAgent/.editorconfig @@ -0,0 +1,6 @@ +[*.cs] + +# IDE0240: Remove redundant nullable directive +# The directive needs to be included since all sources in a source package are considered generated code +# when referenced from a project via package reference. +dotnet_diagnostic.IDE0240.severity = none \ No newline at end of file diff --git a/src/WatchPrototype/HotReloadAgent/AgentReporter.cs b/src/WatchPrototype/HotReloadAgent/AgentReporter.cs new file mode 100644 index 00000000000..36473870efb --- /dev/null +++ b/src/WatchPrototype/HotReloadAgent/AgentReporter.cs @@ -0,0 +1,33 @@ +// 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; +using System.Linq; + +namespace Microsoft.DotNet.HotReload; + +internal sealed class AgentReporter +{ + private readonly List<(string message, AgentMessageSeverity severity)> _log = []; + + public void Report(string message, AgentMessageSeverity severity) + { + _log.Add((message, severity)); + } + + public IReadOnlyCollection<(string message, AgentMessageSeverity severity)> GetAndClearLogEntries(ResponseLoggingLevel level) + { + lock (_log) + { + var filteredLog = (level != ResponseLoggingLevel.Verbose) + ? _log.Where(static entry => entry.severity != AgentMessageSeverity.Verbose) + : _log; + + var log = filteredLog.ToArray(); + _log.Clear(); + return log; + } + } +} diff --git a/src/WatchPrototype/HotReloadAgent/HotReloadAgent.cs b/src/WatchPrototype/HotReloadAgent/HotReloadAgent.cs new file mode 100644 index 00000000000..61af429cfd2 --- /dev/null +++ b/src/WatchPrototype/HotReloadAgent/HotReloadAgent.cs @@ -0,0 +1,324 @@ +// 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.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Runtime.Loader; +using System.Threading; + +namespace Microsoft.DotNet.HotReload; + +#if NET +[System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Hot reload is only expected to work when trimming is disabled.")] +#endif +internal sealed class HotReloadAgent : IDisposable, IHotReloadAgent +{ + private const string MetadataUpdaterTypeName = "System.Reflection.Metadata.MetadataUpdater"; + private const string ApplyUpdateMethodName = "ApplyUpdate"; + private const string GetCapabilitiesMethodName = "GetCapabilities"; + + private delegate void ApplyUpdateDelegate(Assembly assembly, ReadOnlySpan metadataDelta, ReadOnlySpan ilDelta, ReadOnlySpan pdbDelta); + + public AgentReporter Reporter { get; } = new(); + + private readonly ConcurrentDictionary> _moduleUpdates = new(); + private readonly ConcurrentDictionary _appliedAssemblies = new(); + private readonly ApplyUpdateDelegate? _applyUpdate; + private readonly string? _capabilities; + private readonly MetadataUpdateHandlerInvoker _metadataUpdateHandlerInvoker; + + // handler to install on first managed update: + private Func? _assemblyResolvingHandlerToInstall; + private Func? _installedAssemblyResolvingHandler; + + // handler to install to HotReloadException.Created: + private Action? _hotReloadExceptionCreateHandler; + + public HotReloadAgent( + Func? assemblyResolvingHandler, + Action? hotReloadExceptionCreateHandler) + { + _metadataUpdateHandlerInvoker = new(Reporter); + _assemblyResolvingHandlerToInstall = assemblyResolvingHandler; + _hotReloadExceptionCreateHandler = hotReloadExceptionCreateHandler; + GetUpdaterMethodsAndCapabilities(out _applyUpdate, out _capabilities); + + AppDomain.CurrentDomain.AssemblyLoad += OnAssemblyLoad; + } + + public void Dispose() + { + AppDomain.CurrentDomain.AssemblyLoad -= OnAssemblyLoad; + AssemblyLoadContext.Default.Resolving -= _installedAssemblyResolvingHandler; + } + + private void GetUpdaterMethodsAndCapabilities(out ApplyUpdateDelegate? applyUpdate, out string? capabilities) + { + applyUpdate = null; + capabilities = null; + + var metadataUpdater = Type.GetType(MetadataUpdaterTypeName + ", System.Runtime.Loader", throwOnError: false); + if (metadataUpdater == null) + { + Reporter.Report($"Type not found: {MetadataUpdaterTypeName}", AgentMessageSeverity.Error); + return; + } + + var applyUpdateMethod = metadataUpdater.GetMethod(ApplyUpdateMethodName, BindingFlags.Public | BindingFlags.Static, binder: null, [typeof(Assembly), typeof(ReadOnlySpan), typeof(ReadOnlySpan), typeof(ReadOnlySpan)], modifiers: null); + if (applyUpdateMethod == null) + { + Reporter.Report($"{MetadataUpdaterTypeName}.{ApplyUpdateMethodName} not found.", AgentMessageSeverity.Error); + return; + } + + applyUpdate = (ApplyUpdateDelegate)applyUpdateMethod.CreateDelegate(typeof(ApplyUpdateDelegate)); + + var getCapabilities = metadataUpdater.GetMethod(GetCapabilitiesMethodName, BindingFlags.NonPublic | BindingFlags.Static, binder: null, Type.EmptyTypes, modifiers: null); + if (getCapabilities == null) + { + Reporter.Report($"{MetadataUpdaterTypeName}.{GetCapabilitiesMethodName} not found.", AgentMessageSeverity.Error); + return; + } + + try + { + capabilities = getCapabilities.Invoke(obj: null, parameters: null) as string; + } + catch (Exception e) + { + Reporter.Report($"Error retrieving capabilities: {e.Message}", AgentMessageSeverity.Error); + } + } + + public string Capabilities => _capabilities ?? string.Empty; + + private void OnAssemblyLoad(object? _, AssemblyLoadEventArgs eventArgs) + { + _metadataUpdateHandlerInvoker.Clear(); + + var loadedAssembly = eventArgs.LoadedAssembly; + var moduleId = TryGetModuleId(loadedAssembly); + if (moduleId is null) + { + return; + } + + if (_moduleUpdates.TryGetValue(moduleId.Value, out var moduleUpdate) && _appliedAssemblies.TryAdd(loadedAssembly, loadedAssembly)) + { + // A delta for this specific Module exists and we haven't called ApplyUpdate on this instance of Assembly as yet. + ApplyDeltas(loadedAssembly, moduleUpdate); + } + } + + public void ApplyManagedCodeUpdates(IEnumerable updates) + { + Debug.Assert(Capabilities.Length > 0); + Debug.Assert(_applyUpdate != null); + + var handler = Interlocked.Exchange(ref _assemblyResolvingHandlerToInstall, null); + if (handler != null) + { + AssemblyLoadContext.Default.Resolving += handler; + _installedAssemblyResolvingHandler = handler; + } + + foreach (var update in updates) + { + if (update.MetadataDelta.Length == 0) + { + // When the debugger is attached the delta is empty. + // The client only calls to trigger metadata update handlers. + continue; + } + + Reporter.Report($"Applying updates to module {update.ModuleId}.", AgentMessageSeverity.Verbose); + + foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies()) + { + if (TryGetModuleId(assembly) is Guid moduleId && moduleId == update.ModuleId) + { + _applyUpdate(assembly, update.MetadataDelta, update.ILDelta, update.PdbDelta); + } + } + + // Additionally stash the deltas away so it may be applied to assemblies loaded later. + var cachedModuleUpdates = _moduleUpdates.GetOrAdd(update.ModuleId, static _ => []); + cachedModuleUpdates.Add(update); + } + + var updatedTypes = GetMetadataUpdateTypes(updates); + + InstallHotReloadExceptionCreatedHandler(updatedTypes); + + _metadataUpdateHandlerInvoker.MetadataUpdated(updatedTypes); + + Reporter.Report("Updates applied.", AgentMessageSeverity.Verbose); + } + + private void InstallHotReloadExceptionCreatedHandler(Type[] types) + { + if (_hotReloadExceptionCreateHandler is null) + { + // already installed or not available + return; + } + + var exceptionType = types.FirstOrDefault(static t => t.FullName == "System.Runtime.CompilerServices.HotReloadException"); + if (exceptionType == null) + { + return; + } + + var handler = Interlocked.Exchange(ref _hotReloadExceptionCreateHandler, null); + if (handler == null) + { + // already installed or not available + return; + } + + // HotReloadException has a private static field Action Created, unless emitted by previous versions of the compiler: + // See https://github.com/dotnet/roslyn/blob/06f2643e1268e4a7fcdf1221c052f9c8cce20b60/src/Compilers/CSharp/Portable/Symbols/Synthesized/SynthesizedHotReloadExceptionSymbol.cs#L29 + var createdField = exceptionType.GetField("Created", BindingFlags.Static | BindingFlags.NonPublic); + var codeField = exceptionType.GetField("Code", BindingFlags.Public | BindingFlags.Instance); + if (createdField == null || codeField == null) + { + Reporter.Report($"Failed to install HotReloadException handler: not supported by the compiler", AgentMessageSeverity.Verbose); + return; + } + + try + { + createdField.SetValue(null, new Action(e => + { + try + { + handler(codeField.GetValue(e) is int code ? code : 0, e.Message); + } + catch + { + // do not crash the app + } + })); + } + catch (Exception e) + { + Reporter.Report($"Failed to install HotReloadException handler: {e.Message}", AgentMessageSeverity.Verbose); + return; + } + + Reporter.Report($"HotReloadException handler installed.", AgentMessageSeverity.Verbose); + } + + private Type[] GetMetadataUpdateTypes(IEnumerable updates) + { + List? types = null; + + foreach (var update in updates) + { + var assembly = AppDomain.CurrentDomain.GetAssemblies().FirstOrDefault(assembly => TryGetModuleId(assembly) is Guid moduleId && moduleId == update.ModuleId); + if (assembly is null) + { + continue; + } + + foreach (var updatedType in update.UpdatedTypes) + { + // Must be a TypeDef. + Debug.Assert(updatedType >> 24 == 0x02); + + // The type has to be in the manifest module since Hot Reload does not support multi-module assemblies: + try + { + var type = assembly.ManifestModule.ResolveType(updatedType); + types ??= []; + types.Add(type); + } + catch (Exception e) + { + Reporter.Report($"Failed to load type 0x{updatedType:X8}: {e.Message}", AgentMessageSeverity.Warning); + } + } + } + + return types?.ToArray() ?? Type.EmptyTypes; + } + + private void ApplyDeltas(Assembly assembly, IReadOnlyList updates) + { + Debug.Assert(_applyUpdate != null); + + try + { + foreach (var update in updates) + { + _applyUpdate(assembly, update.MetadataDelta, update.ILDelta, update.PdbDelta); + } + + Reporter.Report("Updates applied.", AgentMessageSeverity.Verbose); + } + catch (Exception ex) + { + Reporter.Report(ex.ToString(), AgentMessageSeverity.Warning); + } + } + + private static Guid? TryGetModuleId(Assembly loadedAssembly) + { + try + { + return loadedAssembly.Modules.FirstOrDefault()?.ModuleVersionId; + } + catch + { + // Assembly.Modules might throw. See https://github.com/dotnet/aspnetcore/issues/33152 + } + + return default; + } + + /// + /// Applies the content update. + /// + public void ApplyStaticAssetUpdate(RuntimeStaticAssetUpdate update) + { + _metadataUpdateHandlerInvoker.ContentUpdated(update); + } + + /// + /// Clear any hot-reload specific environment variables. This prevents child processes from being + /// affected by the current app's hot reload settings. See https://github.com/dotnet/runtime/issues/58000 + /// + public static void ClearHotReloadEnvironmentVariables(Type startupHookType) + { + var startupHooks = Environment.GetEnvironmentVariable(AgentEnvironmentVariables.DotNetStartupHooks); + if (!string.IsNullOrEmpty(startupHooks)) + { + Environment.SetEnvironmentVariable(AgentEnvironmentVariables.DotNetStartupHooks, + RemoveCurrentAssembly(startupHookType, startupHooks)); + } + + Environment.SetEnvironmentVariable(AgentEnvironmentVariables.DotNetWatchHotReloadNamedPipeName, null); + Environment.SetEnvironmentVariable(AgentEnvironmentVariables.HotReloadDeltaClientLogMessages, null); + } + + // internal for testing + internal static string RemoveCurrentAssembly(Type startupHookType, string environment) + { + Debug.Assert(!string.IsNullOrEmpty(environment), $"{nameof(environment)} must be set"); + + var comparison = Path.DirectorySeparatorChar == '\\' ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + + var assemblyLocation = startupHookType.Assembly.Location; + var updatedValues = environment.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries) + .Where(e => !string.Equals(e, assemblyLocation, comparison)); + + return string.Join(Path.PathSeparator, updatedValues); + } +} diff --git a/src/WatchPrototype/HotReloadAgent/IHotReloadAgent.cs b/src/WatchPrototype/HotReloadAgent/IHotReloadAgent.cs new file mode 100644 index 00000000000..3e29fdc38b5 --- /dev/null +++ b/src/WatchPrototype/HotReloadAgent/IHotReloadAgent.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. + +#nullable enable + +using System; +using System.Collections.Generic; + +namespace Microsoft.DotNet.HotReload; + +/// +/// Abstraction for testing. +/// +internal interface IHotReloadAgent : IDisposable +{ + AgentReporter Reporter { get; } + string Capabilities { get; } + void ApplyManagedCodeUpdates(IEnumerable updates); + void ApplyStaticAssetUpdate(RuntimeStaticAssetUpdate update); +} diff --git a/src/WatchPrototype/HotReloadAgent/MetadataUpdateHandlerInvoker.cs b/src/WatchPrototype/HotReloadAgent/MetadataUpdateHandlerInvoker.cs new file mode 100644 index 00000000000..e841af26513 --- /dev/null +++ b/src/WatchPrototype/HotReloadAgent/MetadataUpdateHandlerInvoker.cs @@ -0,0 +1,383 @@ +// 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.Diagnostics.CodeAnalysis; +using System.Reflection; +using System.Threading; +using System.Runtime.CompilerServices; + +namespace Microsoft.DotNet.HotReload; + +/// +/// Finds and invokes metadata update handlers. +/// +#if NET +[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Hot reload is only expected to work when trimming is disabled.")] +[UnconditionalSuppressMessage("Trimming", "IL2070", Justification = "Hot reload is only expected to work when trimming is disabled.")] +#endif +internal sealed class MetadataUpdateHandlerInvoker(AgentReporter reporter) +{ + internal delegate void ContentUpdateAction(RuntimeStaticAssetUpdate update); + internal delegate void MetadataUpdateAction(Type[]? updatedTypes); + + internal readonly struct UpdateHandler(TAction action, MethodInfo method) + where TAction : Delegate + { + public TAction Action { get; } = action; + public MethodInfo Method { get; } = method; + + public void ReportInvocation(AgentReporter reporter) + => reporter.Report(GetHandlerDisplayString(Method), AgentMessageSeverity.Verbose); + } + + internal sealed class RegisteredActions( + IReadOnlyList> clearCacheHandlers, + IReadOnlyList> updateApplicationHandlers, + List> updateContentHandlers) + { + public void MetadataUpdated(AgentReporter reporter, Type[] updatedTypes) + { + foreach (var handler in clearCacheHandlers) + { + handler.ReportInvocation(reporter); + handler.Action(updatedTypes); + } + + foreach (var handler in updateApplicationHandlers) + { + handler.ReportInvocation(reporter); + handler.Action(updatedTypes); + } + } + + public void UpdateContent(AgentReporter reporter, RuntimeStaticAssetUpdate update) + { + foreach (var handler in updateContentHandlers) + { + handler.ReportInvocation(reporter); + handler.Action(update); + } + } + + /// + /// For testing. + /// + internal IEnumerable> ClearCacheHandlers => clearCacheHandlers; + + /// + /// For testing. + /// + internal IEnumerable> UpdateApplicationHandlers => updateApplicationHandlers; + + /// + /// For testing. + /// + internal IEnumerable> UpdateContentHandlers => updateContentHandlers; + } + + private const string ClearCacheHandlerName = "ClearCache"; + private const string UpdateApplicationHandlerName = "UpdateApplication"; + private const string UpdateContentHandlerName = "UpdateContent"; + private const BindingFlags HandlerMethodBindingFlags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static; + + private static readonly Type[] s_contentUpdateSignature = [typeof(string), typeof(bool), typeof(string), typeof(byte[])]; + private static readonly Type[] s_metadataUpdateSignature = [typeof(Type[])]; + + private RegisteredActions? _actions; + + /// + /// Call when a new assembly is loaded. + /// + internal void Clear() + => Interlocked.Exchange(ref _actions, null); + + private RegisteredActions GetActions() + { + // Defer discovering metadata updata handlers until after hot reload deltas have been applied. + // This should give enough opportunity for AppDomain.GetAssemblies() to be sufficiently populated. + var actions = _actions; + if (actions == null) + { + Interlocked.CompareExchange(ref _actions, GetUpdateHandlerActions(), null); + actions = _actions; + } + + return actions; + } + + /// + /// Invokes all registered metadata update handlers. + /// + internal void MetadataUpdated(Type[] updatedTypes) + { + try + { + reporter.Report("Invoking metadata update handlers.", AgentMessageSeverity.Verbose); + + GetActions().MetadataUpdated(reporter, updatedTypes); + } + catch (Exception e) + { + reporter.Report(e.ToString(), AgentMessageSeverity.Warning); + } + } + + /// + /// Invokes all registered content update handlers. + /// + internal void ContentUpdated(RuntimeStaticAssetUpdate update) + { + try + { + reporter.Report("Invoking content update handlers.", AgentMessageSeverity.Verbose); + + GetActions().UpdateContent(reporter, update); + } + catch (Exception e) + { + reporter.Report(e.ToString(), AgentMessageSeverity.Warning); + } + } + + private IEnumerable GetHandlerTypes() + { + // We need to execute MetadataUpdateHandlers in a well-defined order. For v1, the strategy that is used is to topologically + // sort assemblies so that handlers in a dependency are executed before the dependent (e.g. the reflection cache action + // in System.Private.CoreLib is executed before System.Text.Json clears its own cache.) + // This would ensure that caches and updates more lower in the application stack are up to date + // before ones higher in the stack are recomputed. + var sortedAssemblies = TopologicalSort(AppDomain.CurrentDomain.GetAssemblies()); + + foreach (var assembly in sortedAssemblies) + { + foreach (var attr in TryGetCustomAttributesData(assembly)) + { + // Look up the attribute by name rather than by type. This would allow netstandard targeting libraries to + // define their own copy without having to cross-compile. + if (attr.AttributeType.FullName != "System.Reflection.Metadata.MetadataUpdateHandlerAttribute") + { + continue; + } + + IList ctorArgs = attr.ConstructorArguments; + if (ctorArgs.Count != 1 || + ctorArgs[0].Value is not Type handlerType) + { + reporter.Report($"'{attr}' found with invalid arguments.", AgentMessageSeverity.Warning); + continue; + } + + yield return handlerType; + } + } + } + + public RegisteredActions GetUpdateHandlerActions() + => GetUpdateHandlerActions(GetHandlerTypes()); + + /// + /// Internal for testing. + /// + internal RegisteredActions GetUpdateHandlerActions(IEnumerable handlerTypes) + { + var clearCacheHandlers = new List>(); + var applicationUpdateHandlers = new List>(); + var contentUpdateHandlers = new List>(); + + foreach (var handlerType in handlerTypes) + { + bool methodFound = false; + + if (GetMetadataUpdateMethod(handlerType, ClearCacheHandlerName) is MethodInfo clearCache) + { + clearCacheHandlers.Add(CreateMetadataUpdateAction(clearCache)); + methodFound = true; + } + + if (GetMetadataUpdateMethod(handlerType, UpdateApplicationHandlerName) is MethodInfo updateApplication) + { + applicationUpdateHandlers.Add(CreateMetadataUpdateAction(updateApplication)); + methodFound = true; + } + + if (GetContentUpdateMethod(handlerType, UpdateContentHandlerName) is MethodInfo updateContent) + { + contentUpdateHandlers.Add(CreateContentUpdateAction(updateContent)); + methodFound = true; + } + + if (!methodFound) + { + reporter.Report( + $"Expected to find a static method '{ClearCacheHandlerName}', '{UpdateApplicationHandlerName}' or '{UpdateContentHandlerName}' on type '{handlerType.AssemblyQualifiedName}' but neither exists.", + AgentMessageSeverity.Warning); + } + } + + return new RegisteredActions(clearCacheHandlers, applicationUpdateHandlers, contentUpdateHandlers); + + UpdateHandler CreateMetadataUpdateAction(MethodInfo method) + { + var action = (MetadataUpdateAction)method.CreateDelegate(typeof(MetadataUpdateAction)); + return new(types => + { + try + { + action(types); + } + catch (Exception e) + { + ReportException(e, method); + } + }, method); + } + + UpdateHandler CreateContentUpdateAction(MethodInfo method) + { + var action = (Action)method.CreateDelegate(typeof(Action)); + return new(update => + { + try + { + action(update.AssemblyName, update.IsApplicationProject, update.RelativePath, update.Contents); + } + catch (Exception e) + { + ReportException(e, method); + } + }, method); + } + + void ReportException(Exception e, MethodInfo method) + => reporter.Report($"Exception from '{GetHandlerDisplayString(method)}': {e}", AgentMessageSeverity.Warning); + + MethodInfo? GetMetadataUpdateMethod(Type handlerType, string name) + { + if (handlerType.GetMethod(name, HandlerMethodBindingFlags, binder: null, s_metadataUpdateSignature, modifiers: null) is MethodInfo updateMethod && + updateMethod.ReturnType == typeof(void)) + { + return updateMethod; + } + + ReportSignatureMismatch(handlerType, name); + return null; + } + + MethodInfo? GetContentUpdateMethod(Type handlerType, string name) + { + if (handlerType.GetMethod(name, HandlerMethodBindingFlags, binder: null, s_contentUpdateSignature, modifiers: null) is MethodInfo updateMethod && + updateMethod.ReturnType == typeof(void)) + { + return updateMethod; + } + + ReportSignatureMismatch(handlerType, name); + return null; + } + + void ReportSignatureMismatch(Type handlerType, string name) + { + foreach (MethodInfo method in handlerType.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance)) + { + if (method.Name == name) + { + reporter.Report($"Type '{handlerType}' has method '{method}' that does not match the required signature.", AgentMessageSeverity.Warning); + break; + } + } + } + } + + private static string GetHandlerDisplayString(MethodInfo method) + => $"{method.DeclaringType!.FullName}.{method.Name}"; + + private IList TryGetCustomAttributesData(Assembly assembly) + { + try + { + return assembly.GetCustomAttributesData(); + } + catch (Exception e) + { + // In cross-platform scenarios, such as debugging in VS through WSL, Roslyn + // runs on Windows, and the agent runs on Linux. Assemblies accessible to Windows + // may not be available or loaded on linux (such as WPF's assemblies). + // In such case, we can ignore the assemblies and continue enumerating handlers for + // the rest of the assemblies of current domain. + reporter.Report($"'{assembly.FullName}' is not loaded ({e.Message})", AgentMessageSeverity.Verbose); + return []; + } + } + + /// + /// Internal for testing. + /// + internal static List TopologicalSort(Assembly[] assemblies) + { + var sortedAssemblies = new List(assemblies.Length); + + var visited = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var assembly in assemblies) + { + Visit(assemblies, assembly, sortedAssemblies, visited); + } + + static void Visit(Assembly[] assemblies, Assembly assembly, List sortedAssemblies, HashSet visited) + { + string? assemblyIdentifier; + + try + { + assemblyIdentifier = assembly.GetName().Name; + } + catch + { + return; + } + + if (assemblyIdentifier == null || !visited.Add(assemblyIdentifier)) + { + return; + } + + AssemblyName[] referencedAssemblies; + try + { + referencedAssemblies = assembly.GetReferencedAssemblies(); + } + catch + { + referencedAssemblies = []; + } + + foreach (var dependencyName in referencedAssemblies) + { + var dependency = Array.Find(assemblies, a => + { + try + { + return string.Equals(a.GetName().Name, dependencyName.Name, StringComparison.OrdinalIgnoreCase); + } + catch + { + return false; + } + }); + + if (dependency is not null) + { + Visit(assemblies, dependency, sortedAssemblies, visited); + } + } + + sortedAssemblies.Add(assembly); + } + + return sortedAssemblies; + } +} diff --git a/src/WatchPrototype/HotReloadAgent/Microsoft.DotNet.HotReload.Agent.Package.csproj b/src/WatchPrototype/HotReloadAgent/Microsoft.DotNet.HotReload.Agent.Package.csproj new file mode 100644 index 00000000000..d44f61dd98c --- /dev/null +++ b/src/WatchPrototype/HotReloadAgent/Microsoft.DotNet.HotReload.Agent.Package.csproj @@ -0,0 +1,37 @@ + + + + + net6.0 + false + none + false + preview + + + true + true + true + Microsoft.DotNet.HotReload.Agent + false + Package containing sources of Hot Reload agent. + + $(NoWarn);NU5128 + true + + + + + + + + + + + + + + + diff --git a/src/WatchPrototype/HotReloadAgent/Microsoft.DotNet.HotReload.Agent.projitems b/src/WatchPrototype/HotReloadAgent/Microsoft.DotNet.HotReload.Agent.projitems new file mode 100644 index 00000000000..c28d7de9cdf --- /dev/null +++ b/src/WatchPrototype/HotReloadAgent/Microsoft.DotNet.HotReload.Agent.projitems @@ -0,0 +1,14 @@ + + + + $(MSBuildAllProjects);$(MSBuildThisFileFullPath) + true + 418B10BD-CA42-49F3-8F4A-D8CC90C8A17D + + + Microsoft.DotNet.HotReload + + + + + \ No newline at end of file diff --git a/src/WatchPrototype/HotReloadAgent/Microsoft.DotNet.HotReload.Agent.shproj b/src/WatchPrototype/HotReloadAgent/Microsoft.DotNet.HotReload.Agent.shproj new file mode 100644 index 00000000000..8bec0ced75e --- /dev/null +++ b/src/WatchPrototype/HotReloadAgent/Microsoft.DotNet.HotReload.Agent.shproj @@ -0,0 +1,13 @@ + + + + {418B10BD-CA42-49F3-8F4A-D8CC90C8A17D} + 14.0 + + + + + + + + \ No newline at end of file diff --git a/src/WatchPrototype/HotReloadClient/.editorconfig b/src/WatchPrototype/HotReloadClient/.editorconfig new file mode 100644 index 00000000000..4694508c4da --- /dev/null +++ b/src/WatchPrototype/HotReloadClient/.editorconfig @@ -0,0 +1,6 @@ +[*.cs] + +# IDE0240: Remove redundant nullable directive +# The directive needs to be included since all sources in a source package are considered generated code +# when referenced from a project via package reference. +dotnet_diagnostic.IDE0240.severity = none \ No newline at end of file diff --git a/src/WatchPrototype/HotReloadClient/DefaultHotReloadClient.cs b/src/WatchPrototype/HotReloadClient/DefaultHotReloadClient.cs new file mode 100644 index 00000000000..1642f63f8b8 --- /dev/null +++ b/src/WatchPrototype/HotReloadClient/DefaultHotReloadClient.cs @@ -0,0 +1,329 @@ +// 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.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; +using Microsoft.Extensions.Logging; + +namespace Microsoft.DotNet.HotReload +{ + internal sealed class DefaultHotReloadClient(ILogger logger, ILogger agentLogger, string startupHookPath, bool enableStaticAssetUpdates) + : 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. + private TaskCompletionSource _updateStatusSource = new(); + + public override void Dispose() + { + DisposePipe(); + } + + 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; + + 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. + _capabilitiesTask = ConnectAsync(); + + async Task> ConnectAsync() + { + try + { + Logger.LogDebug("Waiting for application to connect to pipe {NamedPipeName}.", _namedPipeName); + + await _pipe.WaitForConnectionAsync(cancellationToken); + + // When the client connects, the first payload it sends is the initialization payload which includes the apply capabilities. + + var capabilities = (await ClientInitializationResponse.ReadAsync(_pipe, cancellationToken)).Capabilities; + + var result = AddImplicitCapabilities(capabilities.Split(' ')); + + Logger.Log(LogEvents.Capabilities, string.Join(" ", result)); + + // fire and forget: + _ = ListenForResponsesAsync(cancellationToken); + + return result; + } + catch (Exception e) when (e is not OperationCanceledException) + { + ReportPipeReadException(e, "capabilities", cancellationToken); + 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); + + switch (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)); + break; + + case ResponseType.HotReloadExceptionNotification: + var notification = await HotReloadExceptionCreatedNotification.ReadAsync(_pipe, 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); + return; + } + } + } + catch (Exception e) + { + ReportPipeReadException(e, "response", cancellationToken); + } + } + + [MemberNotNull(nameof(_capabilitiesTask))] + 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) + { + environmentBuilder[AgentEnvironmentVariables.DotNetModifiableAssemblies] = "debug"; + + // HotReload startup hook should be loaded before any other startup hooks: + environmentBuilder.InsertListItem(AgentEnvironmentVariables.DotNetStartupHooks, startupHookPath, Path.PathSeparator); + + environmentBuilder[AgentEnvironmentVariables.DotNetWatchHotReloadNamedPipeName] = _namedPipeName; + } + + public override Task WaitForConnectionEstablishedAsync(CancellationToken cancellationToken) + => GetCapabilitiesTask(); + + public override Task> GetUpdateCapabilitiesAsync(CancellationToken cancellationToken) + => GetCapabilitiesTask(); + + private ResponseLoggingLevel ResponseLoggingLevel + => Logger.IsEnabled(LogLevel.Debug) ? ResponseLoggingLevel.Verbose : ResponseLoggingLevel.WarningsAndErrors; + + public async override Task> ApplyManagedCodeUpdatesAsync(ImmutableArray updates, CancellationToken applyOperationCancellationToken, CancellationToken cancellationToken) + { + RequireReadyForUpdates(); + + if (_managedCodeUpdateFailedOrCancelled) + { + Logger.LogDebug("Previous changes failed to apply. Further changes are not applied to this process."); + return Task.FromResult(false); + } + + var applicableUpdates = await FilterApplicableUpdatesAsync(updates, cancellationToken); + if (applicableUpdates.Count == 0) + { + Logger.LogDebug("No updates applicable to this process"); + return Task.FromResult(true); + } + + var request = new ManagedCodeUpdateRequest(ToRuntimeUpdates(applicableUpdates), ResponseLoggingLevel); + + // Only cancel apply operation when the process exits: + var updateCompletionTask = QueueUpdateBatchRequest(request, applyOperationCancellationToken); + + return CompleteApplyOperationAsync(); + + async Task CompleteApplyOperationAsync() + { + if (await updateCompletionTask) + { + return true; + } + + Logger.LogWarning("Further changes won't be applied to this process."); + _managedCodeUpdateFailedOrCancelled = true; + DisposePipe(); + + return false; + } + + static ImmutableArray ToRuntimeUpdates(IEnumerable updates) + => [.. updates.Select(static update => new RuntimeManagedCodeUpdate(update.ModuleId, + ImmutableCollectionsMarshal.AsArray(update.MetadataDelta)!, + ImmutableCollectionsMarshal.AsArray(update.ILDelta)!, + ImmutableCollectionsMarshal.AsArray(update.PdbDelta)!, + ImmutableCollectionsMarshal.AsArray(update.UpdatedTypes)!))]; + } + + public override async Task> ApplyStaticAssetUpdatesAsync(ImmutableArray updates, CancellationToken processExitedCancellationToken, CancellationToken cancellationToken) + { + if (!enableStaticAssetUpdates) + { + // The client has no concept of static assets. + return Task.FromResult(true); + } + + RequireReadyForUpdates(); + + var completionTasks = updates.Select(update => + { + var request = new StaticAssetUpdateRequest( + new RuntimeStaticAssetUpdate( + update.AssemblyName, + update.RelativePath, + ImmutableCollectionsMarshal.AsArray(update.Content)!, + update.IsApplicationProject), + ResponseLoggingLevel); + + Logger.LogDebug("Sending static file update request for asset '{Url}'.", update.RelativePath); + + // Only cancel apply operation when the process exits: + return QueueUpdateBatchRequest(request, processExitedCancellationToken); + }); + + return CompleteApplyOperationAsync(); + + async Task CompleteApplyOperationAsync() + { + var results = await Task.WhenAll(completionTasks); + return results.All(isSuccess => isSuccess); + } + } + + 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); + + var success = await ReceiveUpdateResponseAsync(applyOperationCancellationToken); + Logger.Log(success ? LogEvents.UpdateBatchCompleted : LogEvents.UpdateBatchFailed, batchId); + return success; + }, + applyOperationCancellationToken); + } + + private async ValueTask ReceiveUpdateResponseAsync(CancellationToken cancellationToken) + { + var result = await _updateStatusSource.Task; + _updateStatusSource = new TaskCompletionSource(); + return result; + } + + private async ValueTask ReadUpdateResponseAsync(CancellationToken cancellationToken) + { + // Should be initialized: + Debug.Assert(_pipe != null); + + var (success, log) = await UpdateResponse.ReadAsync(_pipe, cancellationToken); + + await foreach (var (message, severity) in log) + { + ReportLogEntry(AgentLogger, message, severity); + } + + return success; + } + + public override async Task InitialUpdatesAppliedAsync(CancellationToken cancellationToken) + { + RequireReadyForUpdates(); + + if (_managedCodeUpdateFailedOrCancelled) + { + return; + } + + try + { + await _pipe.WriteAsync((byte)RequestType.InitialUpdatesCompleted, cancellationToken); + await _pipe.FlushAsync(cancellationToken); + } + catch (Exception e) when (e is not OperationCanceledException) + { + // Pipe 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) + { + Logger.LogError("Failed to send {RequestType}: {Message}", nameof(RequestType.InitialUpdatesCompleted), e.Message); + } + } + } + } +} diff --git a/src/WatchPrototype/HotReloadClient/HotReloadClient.cs b/src/WatchPrototype/HotReloadClient/HotReloadClient.cs new file mode 100644 index 00000000000..6e45c8c76d7 --- /dev/null +++ b/src/WatchPrototype/HotReloadClient/HotReloadClient.cs @@ -0,0 +1,179 @@ +// 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.Collections.Immutable; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace Microsoft.DotNet.HotReload; + +internal abstract class HotReloadClient(ILogger logger, ILogger agentLogger) : IDisposable +{ + /// + /// List of modules that can't receive changes anymore. + /// A module is added when a change is requested for it that is not supported by the runtime. + /// + private readonly HashSet _frozenModules = []; + + public readonly ILogger Logger = logger; + public readonly ILogger AgentLogger = agentLogger; + + private int _updateBatchId; + + /// + /// Updates that were sent over to the agent while the process has been suspended. + /// + private readonly object _pendingUpdatesGate = new(); + private Task _pendingUpdates = Task.CompletedTask; + + /// + /// Invoked when a rude edit is detected at runtime. + /// + public event Action? OnRuntimeRudeEdit; + + // for testing + internal Task PendingUpdates + => _pendingUpdates; + + /// + /// .NET Framework runtime does not support adding MethodImpl entries, therefore the capability is not in the baseline capability set. + /// All other runtimes (.NET and Mono) support it and rather than servicing all of them we include the capability here. + /// + protected static ImmutableArray AddImplicitCapabilities(IEnumerable capabilities) + => [.. capabilities, "AddExplicitInterfaceImplementation"]; + + public abstract void ConfigureLaunchEnvironment(IDictionary environmentBuilder); + + /// + /// Initiates connection with the agent in the target process. + /// + /// Cancellation token. The cancellation should trigger on process terminatation. + public abstract void InitiateConnection(CancellationToken cancellationToken); + + /// + /// Waits until the connection with the agent is established. + /// + /// Cancellation token. The cancellation should trigger on process terminatation. + public abstract Task WaitForConnectionEstablishedAsync(CancellationToken cancellationToken); + + /// + /// Returns update capabilities of the target process. + /// + /// Cancellation token. The cancellation should trigger on process terminatation. + public abstract Task> GetUpdateCapabilitiesAsync(CancellationToken cancellationToken); + + /// + /// Returns a task that applies managed code updates to the target process. + /// + /// The token used to cancel creation of the apply task. + /// The token to be used to cancel the apply operation. Should trigger on process terminatation. + public abstract Task> ApplyManagedCodeUpdatesAsync(ImmutableArray updates, CancellationToken applyOperationCancellationToken, CancellationToken cancellationToken); + + /// + /// Returns a task that applies static asset updates to the target process. + /// + /// The token used to cancel creation of the apply task. + /// The token to be used to cancel the apply operation. Should trigger on process terminatation. + public abstract Task> ApplyStaticAssetUpdatesAsync(ImmutableArray updates, CancellationToken applyOperationCancellationToken, CancellationToken cancellationToken); + + /// + /// Notifies the agent that the initial set of updates has been applied and the user code in the process can start executing. + /// + /// Cancellation token. The cancellation should trigger on process terminatation. + public abstract Task InitialUpdatesAppliedAsync(CancellationToken cancellationToken); + + /// + /// Disposes the client. Can occur unexpectedly whenever the process exits. + /// + public abstract void Dispose(); + + protected void RuntimeRudeEditDetected(int errorCode, string message) + => OnRuntimeRudeEdit?.Invoke(errorCode, message); + + public static void ReportLogEntry(ILogger logger, string message, AgentMessageSeverity severity) + { + var level = severity switch + { + AgentMessageSeverity.Error => LogLevel.Error, + AgentMessageSeverity.Warning => LogLevel.Warning, + _ => LogLevel.Debug + }; + + logger.Log(level, message); + } + + protected async Task> FilterApplicableUpdatesAsync(ImmutableArray updates, CancellationToken cancellationToken) + { + var availableCapabilities = await GetUpdateCapabilitiesAsync(cancellationToken); + var applicableUpdates = new List(); + + foreach (var update in updates) + { + if (_frozenModules.Contains(update.ModuleId)) + { + // can't update frozen module: + continue; + } + + if (update.RequiredCapabilities.Except(availableCapabilities).Any()) + { + // required capability not available: + _frozenModules.Add(update.ModuleId); + } + else + { + applicableUpdates.Add(update); + } + } + + return applicableUpdates; + } + + /// + /// Queues a batch of updates to be applied in the target process. + /// + protected Task QueueUpdateBatch(Func> sendAndReceive, CancellationToken applyOperationCancellationToken) + { + var completionSource = new TaskCompletionSource(); + + var batchId = _updateBatchId++; + + Task previous; + lock (_pendingUpdatesGate) + { + previous = _pendingUpdates; + + _pendingUpdates = Task.Run(async () => + { + await previous; + + try + { + Logger.Log(LogEvents.SendingUpdateBatch, batchId); + completionSource.SetResult(await sendAndReceive(batchId)); + } + catch (OperationCanceledException) + { + // 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. + Logger.Log(LogEvents.UpdateBatchCanceled, batchId); + completionSource.SetCanceled(); + } + catch (Exception e) + { + Logger.Log(LogEvents.UpdateBatchFailedWithError, batchId, e.Message); + Logger.Log(LogEvents.UpdateBatchExceptionStackTrace, batchId, e.StackTrace ?? ""); + completionSource.SetResult(false); + } + }, applyOperationCancellationToken); + } + + return completionSource.Task; + } +} diff --git a/src/WatchPrototype/HotReloadClient/HotReloadClients.cs b/src/WatchPrototype/HotReloadClient/HotReloadClients.cs new file mode 100644 index 00000000000..1b02eac9de4 --- /dev/null +++ b/src/WatchPrototype/HotReloadClient/HotReloadClients.cs @@ -0,0 +1,188 @@ +// 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.Collections.Immutable; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace Microsoft.DotNet.HotReload; + +internal sealed class HotReloadClients(ImmutableArray<(HotReloadClient client, string name)> clients, AbstractBrowserRefreshServer? browserRefreshServer) : IDisposable +{ + public HotReloadClients(HotReloadClient client, AbstractBrowserRefreshServer? browserRefreshServer) + : this([(client, "")], browserRefreshServer) + { + } + + /// + /// Disposes all clients. Can occur unexpectedly whenever the process exits. + /// + public void Dispose() + { + foreach (var (client, _) in clients) + { + client.Dispose(); + } + } + + public AbstractBrowserRefreshServer? BrowserRefreshServer + => browserRefreshServer; + + /// + /// Invoked when a rude edit is detected at runtime. + /// May be invoked multiple times, by each client. + /// + public event Action OnRuntimeRudeEdit + { + add + { + foreach (var (client, _) in clients) + { + client.OnRuntimeRudeEdit += value; + } + } + remove + { + foreach (var (client, _) in clients) + { + client.OnRuntimeRudeEdit -= value; + } + } + } + + /// + /// 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) + { + client.ConfigureLaunchEnvironment(environmentBuilder); + } + + browserRefreshServer?.ConfigureLaunchEnvironment(environmentBuilder, enableHotReload: true); + } + + /// Cancellation token. The cancellation should trigger on process terminatation. + internal void InitiateConnection(CancellationToken cancellationToken) + { + foreach (var (client, _) in clients) + { + client.InitiateConnection(cancellationToken); + } + } + + /// Cancellation token. The cancellation should trigger on process terminatation. + internal async ValueTask WaitForConnectionEstablishedAsync(CancellationToken cancellationToken) + { + await Task.WhenAll(clients.Select(c => c.client.WaitForConnectionEstablishedAsync(cancellationToken))); + } + + /// Cancellation token. The cancellation should trigger on process terminatation. + public async ValueTask> GetUpdateCapabilitiesAsync(CancellationToken cancellationToken) + { + if (clients is [var (singleClient, _)]) + { + return await singleClient.GetUpdateCapabilitiesAsync(cancellationToken); + } + + var results = await Task.WhenAll(clients.Select(c => c.client.GetUpdateCapabilitiesAsync(cancellationToken))); + + // Allow updates that are supported by at least one process. + // When applying changes we will filter updates applied to a specific process based on their required capabilities. + return [.. results.SelectMany(r => r).Distinct(StringComparer.Ordinal).OrderBy(c => c)]; + } + + /// Cancellation token. The cancellation should trigger on process terminatation. + public async Task ApplyManagedCodeUpdatesAsync(ImmutableArray updates, CancellationToken applyOperationCancellationToken, CancellationToken cancellationToken) + { + // 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. + // An error is only reported if the delta application fails, which would be a bug either in the runtime (applying valid delta incorrectly), + // the compiler (producing wrong delta), or rude edit detection (the change shouldn't have been allowed). + + var applyTasks = await Task.WhenAll(clients.Select(c => c.client.ApplyManagedCodeUpdatesAsync(updates, applyOperationCancellationToken, cancellationToken))); + + return CompleteApplyOperationAsync(); + + async Task CompleteApplyOperationAsync() + { + var results = await Task.WhenAll(applyTasks); + if (browserRefreshServer != null && results.All(isSuccess => isSuccess)) + { + await browserRefreshServer.RefreshBrowserAsync(cancellationToken); + } + } + } + + /// Cancellation token. The cancellation should trigger on process terminatation. + public async ValueTask InitialUpdatesAppliedAsync(CancellationToken cancellationToken) + { + if (clients is [var (singleClient, _)]) + { + await singleClient.InitialUpdatesAppliedAsync(cancellationToken); + } + else + { + await Task.WhenAll(clients.Select(c => c.client.InitialUpdatesAppliedAsync(cancellationToken))); + } + } + + /// Cancellation token. The cancellation should trigger on process terminatation. + public async Task ApplyStaticAssetUpdatesAsync(IEnumerable assets, CancellationToken applyOperationCancellationToken, CancellationToken cancellationToken) + { + if (browserRefreshServer != null) + { + return browserRefreshServer.UpdateStaticAssetsAsync(assets.Select(static a => a.RelativeUrl), applyOperationCancellationToken).AsTask(); + } + + 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); + continue; + } + } + + return await ApplyStaticAssetUpdatesAsync([.. updates], applyOperationCancellationToken, cancellationToken); + } + + /// Cancellation token. The cancellation should trigger on process terminatation. + public async ValueTask ApplyStaticAssetUpdatesAsync(ImmutableArray updates, CancellationToken applyOperationCancellationToken, CancellationToken cancellationToken) + { + var applyTasks = await Task.WhenAll(clients.Select(c => c.client.ApplyStaticAssetUpdatesAsync(updates, applyOperationCancellationToken, cancellationToken))); + + return Task.WhenAll(applyTasks); + } + + /// Cancellation token. The cancellation should trigger on process terminatation. + public ValueTask ReportCompilationErrorsInApplicationAsync(ImmutableArray compilationErrors, CancellationToken cancellationToken) + => browserRefreshServer?.ReportCompilationErrorsInBrowserAsync(compilationErrors, cancellationToken) ?? ValueTask.CompletedTask; +} diff --git a/src/WatchPrototype/HotReloadClient/HotReloadManagedCodeUpdate.cs b/src/WatchPrototype/HotReloadClient/HotReloadManagedCodeUpdate.cs new file mode 100644 index 00000000000..cf5803262c5 --- /dev/null +++ b/src/WatchPrototype/HotReloadClient/HotReloadManagedCodeUpdate.cs @@ -0,0 +1,25 @@ +// 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.Immutable; + +namespace Microsoft.DotNet.HotReload; + +internal readonly struct HotReloadManagedCodeUpdate( + Guid moduleId, + ImmutableArray metadataDelta, + ImmutableArray ilDelta, + ImmutableArray pdbDelta, + ImmutableArray updatedTypes, + ImmutableArray requiredCapabilities) +{ + public Guid ModuleId { get; } = moduleId; + public ImmutableArray MetadataDelta { get; } = metadataDelta; + public ImmutableArray ILDelta { get; } = ilDelta; + public ImmutableArray PdbDelta { get; } = pdbDelta; + public ImmutableArray UpdatedTypes { get; } = updatedTypes; + public ImmutableArray RequiredCapabilities { get; } = requiredCapabilities; +} diff --git a/src/WatchPrototype/HotReloadClient/HotReloadStaticAssetUpdate.cs b/src/WatchPrototype/HotReloadClient/HotReloadStaticAssetUpdate.cs new file mode 100644 index 00000000000..51c33bae26c --- /dev/null +++ b/src/WatchPrototype/HotReloadClient/HotReloadStaticAssetUpdate.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. + +#nullable enable + +using System.Collections.Immutable; +using System.IO; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.DotNet.HotReload; + +internal readonly struct HotReloadStaticAssetUpdate(string assemblyName, string relativePath, ImmutableArray content, bool isApplicationProject) +{ + public string RelativePath { get; } = relativePath; + public string AssemblyName { get; } = assemblyName; + public ImmutableArray Content { get; } = content; + public bool IsApplicationProject { get; } = isApplicationProject; + + public static async ValueTask CreateAsync(StaticWebAsset asset, CancellationToken cancellationToken) + { +#if NET + var blob = await File.ReadAllBytesAsync(asset.FilePath, cancellationToken); +#else + var blob = File.ReadAllBytes(asset.FilePath); +#endif + return new HotReloadStaticAssetUpdate( + assemblyName: asset.AssemblyName, + relativePath: asset.RelativeUrl, + content: ImmutableCollectionsMarshal.AsImmutableArray(blob), + asset.IsApplicationProject); + } +} diff --git a/src/WatchPrototype/HotReloadClient/Logging/LogEvents.cs b/src/WatchPrototype/HotReloadClient/Logging/LogEvents.cs new file mode 100644 index 00000000000..8b43cb4bad2 --- /dev/null +++ b/src/WatchPrototype/HotReloadClient/Logging/LogEvents.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 Microsoft.Extensions.Logging; + +namespace Microsoft.DotNet.HotReload; + +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) + => 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."); +} diff --git a/src/WatchPrototype/HotReloadClient/Logging/LoggingUtilities.cs b/src/WatchPrototype/HotReloadClient/Logging/LoggingUtilities.cs new file mode 100644 index 00000000000..28b60f04fa7 --- /dev/null +++ b/src/WatchPrototype/HotReloadClient/Logging/LoggingUtilities.cs @@ -0,0 +1,19 @@ +// 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 Microsoft.Extensions.Logging; + +namespace Microsoft.DotNet.HotReload; + +internal static class LoggingUtilities +{ + public static ILogger CreateLogger(this ILoggerFactory factory, string componentName, string displayName) + => factory.CreateLogger($"{componentName}|{displayName}"); + + public static (string comonentName, string? displayName) ParseCategoryName(string categoryName) + => categoryName.IndexOf('|') is int index && index > 0 + ? (categoryName[..index], categoryName[(index + 1)..]) + : (categoryName, null); +} diff --git a/src/WatchPrototype/HotReloadClient/Microsoft.DotNet.HotReload.Client.Package.csproj b/src/WatchPrototype/HotReloadClient/Microsoft.DotNet.HotReload.Client.Package.csproj new file mode 100644 index 00000000000..e07e3c4f0e3 --- /dev/null +++ b/src/WatchPrototype/HotReloadClient/Microsoft.DotNet.HotReload.Client.Package.csproj @@ -0,0 +1,63 @@ + + + + + $(VisualStudioServiceTargetFramework);$(SdkTargetFramework) + false + none + false + preview + + + true + true + true + Microsoft.DotNet.HotReload.Client + false + Package containing sources of Hot Reload client. + + $(NoWarn);NU5128 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + External\%(NuGetPackageId)\%(Link) + + + + + + + diff --git a/src/WatchPrototype/HotReloadClient/Microsoft.DotNet.HotReload.Client.projitems b/src/WatchPrototype/HotReloadClient/Microsoft.DotNet.HotReload.Client.projitems new file mode 100644 index 00000000000..597fca4b663 --- /dev/null +++ b/src/WatchPrototype/HotReloadClient/Microsoft.DotNet.HotReload.Client.projitems @@ -0,0 +1,14 @@ + + + + $(MSBuildAllProjects);$(MSBuildThisFileFullPath) + true + A78FF92A-D715-4249-9E3D-40D9997A098F + + + Microsoft.DotNet.HotReload + + + + + \ No newline at end of file diff --git a/src/WatchPrototype/HotReloadClient/Microsoft.DotNet.HotReload.Client.shproj b/src/WatchPrototype/HotReloadClient/Microsoft.DotNet.HotReload.Client.shproj new file mode 100644 index 00000000000..7503b87c32d --- /dev/null +++ b/src/WatchPrototype/HotReloadClient/Microsoft.DotNet.HotReload.Client.shproj @@ -0,0 +1,13 @@ + + + + A78FF92A-D715-4249-9E3D-40D9997A098F + 14.0 + + + + + + + + \ No newline at end of file diff --git a/src/WatchPrototype/HotReloadClient/Utilities/ArrayBufferWriter.cs b/src/WatchPrototype/HotReloadClient/Utilities/ArrayBufferWriter.cs new file mode 100644 index 00000000000..9bbef4f849d --- /dev/null +++ b/src/WatchPrototype/HotReloadClient/Utilities/ArrayBufferWriter.cs @@ -0,0 +1,266 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +// Based on https://github.com/dotnet/runtime/blob/5d98ad82efb25c1489c6d8f8e8e3daac56f404ec/src/libraries/Common/src/System/Buffers/ArrayBufferWriter.cs + +#if !NET + +#nullable enable + +using System; +using System.Buffers; +using System.Diagnostics; +using System.Runtime.CompilerServices; + +namespace System.Buffers; + +/// +/// Represents a heap-based, array-backed output sink into which data can be written. +/// +internal sealed class ArrayBufferWriter : IBufferWriter +{ + // Copy of Array.MaxLength. + // Used by projects targeting .NET Framework. + private const int ArrayMaxLength = 0x7FFFFFC7; + + private const int DefaultInitialBufferSize = 256; + + private T[] _buffer; + private int _index; + + /// + /// Creates an instance of an , in which data can be written to, + /// with the default initial capacity. + /// + public ArrayBufferWriter() + { + _buffer = []; + _index = 0; + } + + /// + /// Creates an instance of an , in which data can be written to, + /// with an initial capacity specified. + /// + /// The minimum capacity with which to initialize the underlying buffer. + /// + /// Thrown when is not positive (i.e. less than or equal to 0). + /// + public ArrayBufferWriter(int initialCapacity) + { + if (initialCapacity <= 0) + throw new ArgumentException(null, nameof(initialCapacity)); + + _buffer = new T[initialCapacity]; + _index = 0; + } + + /// + /// Returns the data written to the underlying buffer so far, as a . + /// + public ReadOnlyMemory WrittenMemory => _buffer.AsMemory(0, _index); + + /// + /// Returns the data written to the underlying buffer so far, as a . + /// + public ReadOnlySpan WrittenSpan => _buffer.AsSpan(0, _index); + + /// + /// Returns the amount of data written to the underlying buffer so far. + /// + public int WrittenCount => _index; + + /// + /// Returns the total amount of space within the underlying buffer. + /// + public int Capacity => _buffer.Length; + + /// + /// Returns the amount of space available that can still be written into without forcing the underlying buffer to grow. + /// + public int FreeCapacity => _buffer.Length - _index; + + /// + /// Clears the data written to the underlying buffer. + /// + /// + /// + /// You must reset or clear the before trying to re-use it. + /// + /// + /// The method is faster since it only sets to zero the writer's index + /// while the method additionally zeroes the content of the underlying buffer. + /// + /// + /// + public void Clear() + { + Debug.Assert(_buffer.Length >= _index); + _buffer.AsSpan(0, _index).Clear(); + _index = 0; + } + + /// + /// Resets the data written to the underlying buffer without zeroing its content. + /// + /// + /// + /// You must reset or clear the before trying to re-use it. + /// + /// + /// If you reset the writer using the method, the underlying buffer will not be cleared. + /// + /// + /// + public void ResetWrittenCount() => _index = 0; + + /// + /// Notifies that amount of data was written to the output / + /// + /// + /// Thrown when is negative. + /// + /// + /// Thrown when attempting to advance past the end of the underlying buffer. + /// + /// + /// You must request a new buffer after calling Advance to continue writing more data and cannot write to a previously acquired buffer. + /// + public void Advance(int count) + { + if (count < 0) + throw new ArgumentException(null, nameof(count)); + + if (_index > _buffer.Length - count) + ThrowInvalidOperationException_AdvancedTooFar(_buffer.Length); + + _index += count; + } + + /// + /// Returns a to write to that is at least the requested length (specified by ). + /// If no is provided (or it's equal to 0), some non-empty buffer is returned. + /// + /// + /// Thrown when is negative. + /// + /// + /// + /// This will never return an empty . + /// + /// + /// There is no guarantee that successive calls will return the same buffer or the same-sized buffer. + /// + /// + /// You must request a new buffer after calling Advance to continue writing more data and cannot write to a previously acquired buffer. + /// + /// + /// If you reset the writer using the method, this method may return a non-cleared . + /// + /// + /// If you clear the writer using the method, this method will return a with its content zeroed. + /// + /// + public Memory GetMemory(int sizeHint = 0) + { + CheckAndResizeBuffer(sizeHint); + Debug.Assert(_buffer.Length > _index); + return _buffer.AsMemory(_index); + } + + public ArraySegment GetArraySegment(int sizeHint = 0) + { + CheckAndResizeBuffer(sizeHint); + Debug.Assert(_buffer.Length > _index); + return new ArraySegment(_buffer, _index, _buffer.Length - _index); + } + + /// + /// Returns a to write to that is at least the requested length (specified by ). + /// If no is provided (or it's equal to 0), some non-empty buffer is returned. + /// + /// + /// Thrown when is negative. + /// + /// + /// + /// This will never return an empty . + /// + /// + /// There is no guarantee that successive calls will return the same buffer or the same-sized buffer. + /// + /// + /// You must request a new buffer after calling Advance to continue writing more data and cannot write to a previously acquired buffer. + /// + /// + /// If you reset the writer using the method, this method may return a non-cleared . + /// + /// + /// If you clear the writer using the method, this method will return a with its content zeroed. + /// + /// + public Span GetSpan(int sizeHint = 0) + { + CheckAndResizeBuffer(sizeHint); + Debug.Assert(_buffer.Length > _index); + return _buffer.AsSpan(_index); + } + + private void CheckAndResizeBuffer(int sizeHint) + { + if (sizeHint < 0) + throw new ArgumentException(nameof(sizeHint)); + + if (sizeHint == 0) + { + sizeHint = 1; + } + + if (sizeHint > FreeCapacity) + { + int currentLength = _buffer.Length; + + // Attempt to grow by the larger of the sizeHint and double the current size. + int growBy = Math.Max(sizeHint, currentLength); + + if (currentLength == 0) + { + growBy = Math.Max(growBy, DefaultInitialBufferSize); + } + + int newSize = currentLength + growBy; + + if ((uint)newSize > int.MaxValue) + { + // Attempt to grow to ArrayMaxLength. + uint needed = (uint)(currentLength - FreeCapacity + sizeHint); + Debug.Assert(needed > currentLength); + + if (needed > ArrayMaxLength) + { + ThrowOutOfMemoryException(needed); + } + + newSize = ArrayMaxLength; + } + + Array.Resize(ref _buffer, newSize); + } + + Debug.Assert(FreeCapacity > 0 && FreeCapacity >= sizeHint); + } + + private static void ThrowInvalidOperationException_AdvancedTooFar(int capacity) + { + throw new InvalidOperationException($"Buffer writer advanced too far: {capacity}"); + } + + private static void ThrowOutOfMemoryException(uint capacity) + { +#pragma warning disable CA2201 // Do not raise reserved exception types + throw new OutOfMemoryException($"Buffer maximum size exceeded: {capacity}"); +#pragma warning restore CA2201 + } +} + +#endif diff --git a/src/WatchPrototype/HotReloadClient/Utilities/EnvironmentUtilities.cs b/src/WatchPrototype/HotReloadClient/Utilities/EnvironmentUtilities.cs new file mode 100644 index 00000000000..d52af59f52a --- /dev/null +++ b/src/WatchPrototype/HotReloadClient/Utilities/EnvironmentUtilities.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. + +#nullable enable + +using System; +using System.Collections.Generic; + +namespace Microsoft.DotNet.HotReload; + +internal static class EnvironmentUtilities +{ + public static void InsertListItem(this IDictionary environment, string key, string value, char separator) + { + if (!environment.TryGetValue(key, out var existingValue) || existingValue is "") + { + environment[key] = value; + } + else if (existingValue.Split(separator).IndexOf(value) == -1) + { + environment[key] = value + separator + existingValue; + } + } +} diff --git a/src/WatchPrototype/HotReloadClient/Utilities/PathExtensions.cs b/src/WatchPrototype/HotReloadClient/Utilities/PathExtensions.cs new file mode 100644 index 00000000000..8e91575e0c8 --- /dev/null +++ b/src/WatchPrototype/HotReloadClient/Utilities/PathExtensions.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. +// See the LICENSE file in the project root for more information. + +#nullable enable + +using System.Diagnostics; +using System.Runtime.CompilerServices; +using System.Text; + +namespace System.IO; + +internal static partial class PathExtensions +{ +#if NET // binary compatibility + public static bool IsPathFullyQualified(string path) + => Path.IsPathFullyQualified(path); + + public static string Join(string? path1, string? path2) + => Path.Join(path1, path2); +#else + extension(Path) + { + public static bool IsPathFullyQualified(string path) + => Path.DirectorySeparatorChar == '\\' + ? !IsPartiallyQualified(path.AsSpan()) + : Path.IsPathRooted(path); + } + + // Copied from https://github.com/dotnet/runtime/blob/a6c5ba30aab998555e36aec7c04311935e1797ab/src/libraries/Common/src/System/IO/PathInternal.Windows.cs#L250 + + /// + /// Returns true if the path specified is relative to the current drive or working directory. + /// Returns false if the path is fixed to a specific drive or UNC path. This method does no + /// validation of the path (URIs will be returned as relative as a result). + /// + /// + /// Handles paths that use the alternate directory separator. It is a frequent mistake to + /// assume that rooted paths (Path.IsPathRooted) are not relative. This isn't the case. + /// "C:a" is drive relative- meaning that it will be resolved against the current directory + /// for C: (rooted, but relative). "C:\a" is rooted and not relative (the current directory + /// will not be used to modify the path). + /// + private static bool IsPartiallyQualified(ReadOnlySpan path) + { + if (path.Length < 2) + { + // It isn't fixed, it must be relative. There is no way to specify a fixed + // path with one character (or less). + return true; + } + + if (IsDirectorySeparator(path[0])) + { + // There is no valid way to specify a relative path with two initial slashes or + // \? as ? isn't valid for drive relative paths and \??\ is equivalent to \\?\ + return !(path[1] == '?' || IsDirectorySeparator(path[1])); + } + + // The only way to specify a fixed path that doesn't begin with two slashes + // is the drive, colon, slash format- i.e. C:\ + return !((path.Length >= 3) + && (path[1] == Path.VolumeSeparatorChar) + && IsDirectorySeparator(path[2]) + // To match old behavior we'll check the drive character for validity as the path is technically + // not qualified if you don't have a valid drive. "=:\" is the "=" file's default data stream. + && IsValidDriveChar(path[0])); + } + + /// + /// True if the given character is a directory separator. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static bool IsDirectorySeparator(char c) + { + return c == Path.DirectorySeparatorChar || c == Path.AltDirectorySeparatorChar; + } + + /// + /// Returns true if the given character is a valid drive letter + /// + internal static bool IsValidDriveChar(char value) + { + return (uint)((value | 0x20) - 'a') <= (uint)('z' - 'a'); + } + + // Copied from https://github.com/dotnet/runtime/blob/main/src/libraries/System.Private.CoreLib/src/System/IO/Path.cs + + private static readonly string s_directorySeparatorCharAsString = Path.DirectorySeparatorChar.ToString(); + + extension(Path) + { + public static string Join(string? path1, string? path2) + { + if (string.IsNullOrEmpty(path1)) + return path2 ?? string.Empty; + + if (string.IsNullOrEmpty(path2)) + return path1; + + return JoinInternal(path1, path2); + } + } + + private static string JoinInternal(string first, string second) + { + Debug.Assert(first.Length > 0 && second.Length > 0, "should have dealt with empty paths"); + + bool hasSeparator = IsDirectorySeparator(first[^1]) || IsDirectorySeparator(second[0]); + + return hasSeparator ? + string.Concat(first, second) : + string.Concat(first, s_directorySeparatorCharAsString, second); + } +#endif +} diff --git a/src/WatchPrototype/HotReloadClient/Utilities/ResponseFunc.cs b/src/WatchPrototype/HotReloadClient/Utilities/ResponseFunc.cs new file mode 100644 index 00000000000..160bce801a6 --- /dev/null +++ b/src/WatchPrototype/HotReloadClient/Utilities/ResponseFunc.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 + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace Microsoft.DotNet.HotReload; + +// Workaround for ReadOnlySpan not working as a generic parameter on .NET Framework +public delegate TResult ResponseFunc(ReadOnlySpan data, ILogger logger); diff --git a/src/WatchPrototype/HotReloadClient/Utilities/VoidResult.cs b/src/WatchPrototype/HotReloadClient/Utilities/VoidResult.cs new file mode 100644 index 00000000000..d024e9cb07e --- /dev/null +++ b/src/WatchPrototype/HotReloadClient/Utilities/VoidResult.cs @@ -0,0 +1,10 @@ +// 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 VoidResult +{ +} diff --git a/src/WatchPrototype/HotReloadClient/Web/AbstractBrowserRefreshServer.cs b/src/WatchPrototype/HotReloadClient/Web/AbstractBrowserRefreshServer.cs new file mode 100644 index 00000000000..79c5d957265 --- /dev/null +++ b/src/WatchPrototype/HotReloadClient/Web/AbstractBrowserRefreshServer.cs @@ -0,0 +1,344 @@ +// 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.Collections.Immutable; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.WebSockets; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.Extensions.Logging; + +namespace Microsoft.DotNet.HotReload; + +/// +/// Communicates with aspnetcore-browser-refresh.js loaded in the browser. +/// Associated with a project instance. +/// +internal abstract class AbstractBrowserRefreshServer(string middlewareAssemblyPath, ILogger logger, ILoggerFactory loggerFactory) : IDisposable +{ + public const string ServerLogComponentName = "BrowserRefreshServer"; + + private static readonly JsonSerializerOptions s_jsonSerializerOptions = new(JsonSerializerDefaults.Web); + + private readonly List _activeConnections = []; + private readonly TaskCompletionSource _browserConnected = new(TaskCreationOptions.RunContinuationsAsynchronously); + + private readonly SharedSecretProvider _sharedSecretProvider = new(); + + // initialized by StartAsync + private WebServerHost? _lazyHost; + + public virtual void Dispose() + { + BrowserConnection[] connectionsToDispose; + lock (_activeConnections) + { + connectionsToDispose = [.. _activeConnections]; + _activeConnections.Clear(); + } + + foreach (var connection in connectionsToDispose) + { + connection.Dispose(); + } + + _lazyHost?.Dispose(); + _sharedSecretProvider.Dispose(); + } + + protected abstract ValueTask CreateAndStartHostAsync(CancellationToken cancellationToken); + protected abstract bool SuppressTimeouts { get; } + + public ILogger Logger + => logger; + + public async ValueTask StartAsync(CancellationToken cancellationToken) + { + if (_lazyHost != null) + { + throw new InvalidOperationException("Server already started"); + } + + _lazyHost = await CreateAndStartHostAsync(cancellationToken); + logger.Log(LogEvents.RefreshServerRunningAt, string.Join(",", _lazyHost.EndPoints)); + } + + public void ConfigureLaunchEnvironment(IDictionary builder, bool enableHotReload) + { + if (_lazyHost == null) + { + throw new InvalidOperationException("Server not started"); + } + + builder[MiddlewareEnvironmentVariables.AspNetCoreAutoReloadWSEndPoint] = string.Join(",", _lazyHost.EndPoints); + builder[MiddlewareEnvironmentVariables.AspNetCoreAutoReloadWSKey] = _sharedSecretProvider.GetPublicKey(); + builder[MiddlewareEnvironmentVariables.AspNetCoreAutoReloadVirtualDirectory] = _lazyHost.VirtualDirectory; + + builder.InsertListItem(MiddlewareEnvironmentVariables.DotNetStartupHooks, middlewareAssemblyPath, Path.PathSeparator); + builder.InsertListItem(MiddlewareEnvironmentVariables.AspNetCoreHostingStartupAssemblies, Path.GetFileNameWithoutExtension(middlewareAssemblyPath), MiddlewareEnvironmentVariables.AspNetCoreHostingStartupAssembliesSeparator); + + if (enableHotReload) + { + // Note: + // Microsoft.AspNetCore.Components.WebAssembly.Server.ComponentWebAssemblyConventions and Microsoft.AspNetCore.Watch.BrowserRefresh.BrowserRefreshMiddleware + // expect DOTNET_MODIFIABLE_ASSEMBLIES to be set in the blazor-devserver process, even though we are not performing Hot Reload in this process. + // The value is converted to DOTNET-MODIFIABLE-ASSEMBLIES header, which is in turn converted back to environment variable in Mono browser runtime loader: + // https://github.com/dotnet/runtime/blob/342936c5a88653f0f622e9d6cb727a0e59279b31/src/mono/browser/runtime/loader/config.ts#L330 + builder[MiddlewareEnvironmentVariables.DotNetModifiableAssemblies] = "debug"; + } + + if (logger.IsEnabled(LogLevel.Trace)) + { + // enable debug logging from middleware: + builder[MiddlewareEnvironmentVariables.LoggingLevel] = "Debug"; + } + } + + protected BrowserConnection OnBrowserConnected(WebSocket clientSocket, string? subProtocol) + { + var sharedSecret = (subProtocol != null) ? _sharedSecretProvider.DecryptSecret(WebUtility.UrlDecode(subProtocol)) : null; + + var connection = new BrowserConnection(clientSocket, sharedSecret, loggerFactory); + + lock (_activeConnections) + { + _activeConnections.Add(connection); + } + + _browserConnected.TrySetResult(default); + return connection; + } + + /// + /// For testing. + /// + internal void EmulateClientConnected() + { + _browserConnected.TrySetResult(default); + } + + public async Task WaitForClientConnectionAsync(CancellationToken cancellationToken) + { + using var progressCancellationSource = new CancellationTokenSource(); + + // It make take a while to connect since the app might need to build first. + // Indicate progress in the output. Start with 60s and then report progress every 10s. + var firstReportSeconds = TimeSpan.FromSeconds(60); + var nextReportSeconds = TimeSpan.FromSeconds(10); + + var reportDelayInSeconds = firstReportSeconds; + var connectionAttemptReported = false; + + var progressReportingTask = Task.Run(async () => + { + try + { + while (!progressCancellationSource.Token.IsCancellationRequested) + { + await Task.Delay(SuppressTimeouts ? TimeSpan.MaxValue : reportDelayInSeconds, progressCancellationSource.Token); + + connectionAttemptReported = true; + reportDelayInSeconds = nextReportSeconds; + logger.LogInformation("Connecting to the browser ..."); + } + } + catch (OperationCanceledException) + { + // nop + } + }, progressCancellationSource.Token); + + // Work around lack of Task.WaitAsync(cancellationToken) on .NET Framework: + cancellationToken.Register(() => _browserConnected.TrySetCanceled()); + + try + { + await _browserConnected.Task; + } + finally + { + progressCancellationSource.Cancel(); + } + + if (connectionAttemptReported) + { + logger.LogInformation("Browser connection established."); + } + } + + private IReadOnlyCollection GetOpenBrowserConnections() + { + lock (_activeConnections) + { + return [.. _activeConnections.Where(b => b.ClientSocket.State == WebSocketState.Open)]; + } + } + + private void DisposeClosedBrowserConnections() + { + List? lazyConnectionsToDispose = null; + + lock (_activeConnections) + { + var j = 0; + for (var i = 0; i < _activeConnections.Count; i++) + { + var connection = _activeConnections[i]; + if (connection.ClientSocket.State == WebSocketState.Open) + { + _activeConnections[j++] = connection; + } + else + { + lazyConnectionsToDispose ??= []; + lazyConnectionsToDispose.Add(connection); + } + } + + _activeConnections.RemoveRange(j, _activeConnections.Count - j); + } + + if (lazyConnectionsToDispose != null) + { + foreach (var connection in lazyConnectionsToDispose) + { + connection.Dispose(); + } + } + } + + public static ReadOnlyMemory SerializeJson(TValue value) + => JsonSerializer.SerializeToUtf8Bytes(value, s_jsonSerializerOptions); + + public static TValue DeserializeJson(ReadOnlySpan value) + => JsonSerializer.Deserialize(value, s_jsonSerializerOptions) ?? throw new InvalidDataException("Unexpected null object"); + + public ValueTask SendJsonMessageAsync(TValue value, CancellationToken cancellationToken) + => SendAsync(SerializeJson(value), cancellationToken); + + public ValueTask SendReloadMessageAsync(CancellationToken cancellationToken) + { + logger.Log(LogEvents.ReloadingBrowser); + return SendAsync(JsonReloadRequest.Message, cancellationToken); + } + + public ValueTask SendWaitMessageAsync(CancellationToken cancellationToken) + { + logger.Log(LogEvents.SendingWaitMessage); + return SendAsync(JsonWaitRequest.Message, cancellationToken); + } + + private async ValueTask SendAsync(ReadOnlyMemory messageBytes, CancellationToken cancellationToken) + { + await SendAndReceiveAsync, VoidResult>(request: _ => messageBytes, response: null, cancellationToken); + } + + public async ValueTask SendAndReceiveAsync( + Func? request, + ResponseFunc? response, + CancellationToken cancellationToken) + where TResult : struct + { + var responded = false; + var openConnections = GetOpenBrowserConnections(); + var result = default(TResult?); + + foreach (var connection in openConnections) + { + if (request != null) + { + var requestValue = request(connection.SharedSecret); + var requestBytes = requestValue is ReadOnlyMemory bytes ? bytes : SerializeJson(requestValue); + + if (!await connection.TrySendMessageAsync(requestBytes, cancellationToken)) + { + continue; + } + } + + if (response != null && (result = await connection.TryReceiveMessageAsync(response, cancellationToken)) == null) + { + continue; + } + + responded = true; + } + + if (openConnections.Count == 0) + { + logger.Log(LogEvents.NoBrowserConnected); + } + else if (response != null && !responded) + { + logger.Log(LogEvents.FailedToReceiveResponseFromConnectedBrowser); + } + + DisposeClosedBrowserConnections(); + return result; + } + + public ValueTask RefreshBrowserAsync(CancellationToken cancellationToken) + { + logger.Log(LogEvents.RefreshingBrowser); + return SendAsync(JsonRefreshBrowserRequest.Message, cancellationToken); + } + + public ValueTask ReportCompilationErrorsInBrowserAsync(ImmutableArray compilationErrors, CancellationToken cancellationToken) + { + logger.Log(LogEvents.UpdatingDiagnostics); + return SendJsonMessageAsync(new JsonReportDiagnosticsRequest { Diagnostics = compilationErrors }, cancellationToken); + } + + public async ValueTask UpdateStaticAssetsAsync(IEnumerable relativeUrls, CancellationToken cancellationToken) + { + // Serialize all requests sent to a single server: + foreach (var relativeUrl in relativeUrls) + { + logger.Log(LogEvents.SendingStaticAssetUpdateRequest, relativeUrl); + var message = JsonSerializer.SerializeToUtf8Bytes(new JasonUpdateStaticFileRequest { Path = relativeUrl }, s_jsonSerializerOptions); + await SendAsync(message, cancellationToken); + } + } + + private readonly struct JsonWaitRequest + { + public string Type => "Wait"; + public static readonly ReadOnlyMemory Message = JsonSerializer.SerializeToUtf8Bytes(new JsonWaitRequest(), s_jsonSerializerOptions); + } + + private readonly struct JsonReloadRequest + { + public string Type => "Reload"; + public static readonly ReadOnlyMemory Message = JsonSerializer.SerializeToUtf8Bytes(new JsonReloadRequest(), s_jsonSerializerOptions); + } + + private readonly struct JsonRefreshBrowserRequest + { + public string Type => "RefreshBrowser"; + public static readonly ReadOnlyMemory Message = JsonSerializer.SerializeToUtf8Bytes(new JsonRefreshBrowserRequest(), s_jsonSerializerOptions); + } + + private readonly struct JsonReportDiagnosticsRequest + { + public string Type => "ReportDiagnostics"; + + public IEnumerable Diagnostics { get; init; } + } + + private readonly struct JasonUpdateStaticFileRequest + { + public string Type => "UpdateStaticFile"; + public string Path { get; init; } + } +} diff --git a/src/WatchPrototype/HotReloadClient/Web/BrowserConnection.cs b/src/WatchPrototype/HotReloadClient/Web/BrowserConnection.cs new file mode 100644 index 00000000000..74bfabc268d --- /dev/null +++ b/src/WatchPrototype/HotReloadClient/Web/BrowserConnection.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. + +#nullable enable + +using System; +using System.Buffers; +using System.Net.WebSockets; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace Microsoft.DotNet.HotReload; + +internal readonly struct BrowserConnection : IDisposable +{ + public const string ServerLogComponentName = $"{nameof(BrowserConnection)}:Server"; + public const string AgentLogComponentName = $"{nameof(BrowserConnection)}:Agent"; + + private static int s_lastId; + + public WebSocket ClientSocket { get; } + public string? SharedSecret { get; } + public int Id { get; } + public ILogger ServerLogger { get; } + public ILogger AgentLogger { get; } + + public readonly TaskCompletionSource Disconnected = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public BrowserConnection(WebSocket clientSocket, string? sharedSecret, ILoggerFactory loggerFactory) + { + ClientSocket = clientSocket; + SharedSecret = sharedSecret; + Id = Interlocked.Increment(ref s_lastId); + + var displayName = $"Browser #{Id}"; + ServerLogger = loggerFactory.CreateLogger(ServerLogComponentName, displayName); + AgentLogger = loggerFactory.CreateLogger(AgentLogComponentName, displayName); + + ServerLogger.Log(LogEvents.ConnectedToRefreshServer); + } + + public void Dispose() + { + ClientSocket.Dispose(); + + Disconnected.TrySetResult(default); + ServerLogger.LogDebug("Disconnected."); + } + + internal async ValueTask TrySendMessageAsync(ReadOnlyMemory messageBytes, CancellationToken cancellationToken) + { +#if NET + var data = messageBytes; +#else + var data = new ArraySegment(messageBytes.ToArray()); +#endif + try + { + await ClientSocket.SendAsync(data, WebSocketMessageType.Text, endOfMessage: true, cancellationToken); + } + catch (Exception e) when (e is not OperationCanceledException) + { + ServerLogger.LogDebug("Failed to send message: {Message}", e.Message); + return false; + } + + return true; + } + + internal async ValueTask TryReceiveMessageAsync(ResponseFunc receiver, CancellationToken cancellationToken) + where TResponseResult : struct + { + var writer = new ArrayBufferWriter(initialCapacity: 1024); + + while (true) + { +#if NET + ValueWebSocketReceiveResult result; + var data = writer.GetMemory(); +#else + WebSocketReceiveResult result; + var data = writer.GetArraySegment(); +#endif + try + { + result = await ClientSocket.ReceiveAsync(data, cancellationToken); + } + catch (Exception e) when (e is not OperationCanceledException) + { + ServerLogger.LogDebug("Failed to receive response: {Message}", e.Message); + return null; + } + + if (result.MessageType == WebSocketMessageType.Close) + { + return null; + } + + writer.Advance(result.Count); + if (result.EndOfMessage) + { + break; + } + } + + return receiver(writer.WrittenSpan, AgentLogger); + } +} diff --git a/src/WatchPrototype/HotReloadClient/Web/BrowserRefreshServer.cs b/src/WatchPrototype/HotReloadClient/Web/BrowserRefreshServer.cs new file mode 100644 index 00000000000..241bcbe0d13 --- /dev/null +++ b/src/WatchPrototype/HotReloadClient/Web/BrowserRefreshServer.cs @@ -0,0 +1,151 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable enable + +#if NET + +using System; +using System.Collections.Immutable; +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. +/// +internal sealed class BrowserRefreshServer( + ILogger logger, + ILoggerFactory loggerFactory, + string middlewareAssemblyPath, + string dotnetPath, + string? autoReloadWebSocketHostName, + int? autoReloadWebSocketPort, + 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 + { + result = false; + } + + 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))]; + } + + return + [ + serverUrls + .First() + .Replace("https://", "wss://", StringComparison.Ordinal) + .Replace("http://", "ws://", StringComparison.Ordinal) + ]; + } + + private async Task WebSocketRequestAsync(HttpContext context) + { + if (!context.WebSockets.IsWebSocketRequest) + { + context.Response.StatusCode = 400; + return; + } + + if (context.WebSockets.WebSocketRequestedProtocols is not [var subProtocol]) + { + subProtocol = null; + } + + var clientSocket = await context.WebSockets.AcceptWebSocketAsync(subProtocol); + + var connection = OnBrowserConnected(clientSocket, subProtocol); + await connection.Disconnected.Task; + } +} + +#endif diff --git a/src/WatchPrototype/HotReloadClient/Web/MiddlewareEnvironmentVariables.cs b/src/WatchPrototype/HotReloadClient/Web/MiddlewareEnvironmentVariables.cs new file mode 100644 index 00000000000..ea4754a54e0 --- /dev/null +++ b/src/WatchPrototype/HotReloadClient/Web/MiddlewareEnvironmentVariables.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 + +namespace Microsoft.DotNet.HotReload; + +internal static class MiddlewareEnvironmentVariables +{ + /// + /// dotnet runtime environment variable used to load middleware assembly into the web server process. + /// https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-environment-variables#dotnet_startup_hooks + /// + public const string DotNetStartupHooks = "DOTNET_STARTUP_HOOKS"; + + /// + /// dotnet runtime environment variable. + /// + public const string DotNetModifiableAssemblies = "DOTNET_MODIFIABLE_ASSEMBLIES"; + + /// + /// Simple names of assemblies that implement middleware components to be added to the web server. + /// + public const string AspNetCoreHostingStartupAssemblies = "ASPNETCORE_HOSTINGSTARTUPASSEMBLIES"; + public const char AspNetCoreHostingStartupAssembliesSeparator = ';'; + + /// + /// Comma-separated list of WebSocket end points to communicate with browser refresh client. + /// + public const string AspNetCoreAutoReloadWSEndPoint = "ASPNETCORE_AUTO_RELOAD_WS_ENDPOINT"; + + public const string AspNetCoreAutoReloadVirtualDirectory = "ASPNETCORE_AUTO_RELOAD_VDIR"; + + /// + /// Public key to use to communicate with browser refresh client. + /// + public const string AspNetCoreAutoReloadWSKey = "ASPNETCORE_AUTO_RELOAD_WS_KEY"; + + /// + /// Variable used to set the logging level of the middleware logger. + /// + public const string LoggingLevel = "Logging__LogLevel__Microsoft.AspNetCore.Watch"; +} diff --git a/src/WatchPrototype/HotReloadClient/Web/SharedSecretProvider.cs b/src/WatchPrototype/HotReloadClient/Web/SharedSecretProvider.cs new file mode 100644 index 00000000000..813a1b12649 --- /dev/null +++ b/src/WatchPrototype/HotReloadClient/Web/SharedSecretProvider.cs @@ -0,0 +1,156 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. + +#nullable enable + +using System; +using System.IO; +using System.Security.Cryptography; + +namespace Microsoft.DotNet.HotReload; + +internal sealed class SharedSecretProvider : IDisposable +{ + private readonly RSA _rsa = RSA.Create(2048); + + public void Dispose() + => _rsa.Dispose(); + + internal string DecryptSecret(string secret) + => Convert.ToBase64String(_rsa.Decrypt(Convert.FromBase64String(secret), RSAEncryptionPadding.OaepSHA256)); + + internal string GetPublicKey() +#if NET + => Convert.ToBase64String(_rsa.ExportSubjectPublicKeyInfo()); +#else + => ExportPublicKeyNetFramework(); +#endif + + /// + /// Export the public key in the X.509 SubjectPublicKeyInfo representation which is equivalent to the .NET Core RSA api + /// ExportSubjectPublicKeyInfo. + /// + /// Algorithm from https://stackoverflow.com/a/28407693 or https://github.com/Azure/azure-powershell/blob/main/src/KeyVault/KeyVault/Helpers/JwkHelper.cs + /// + internal string ExportPublicKeyNetFramework() + { + var writer = new StringWriter(); + ExportPublicKey(ExportPublicKeyParameters(), writer); + return writer.ToString(); + } + + internal RSAParameters ExportPublicKeyParameters() + => _rsa.ExportParameters(includePrivateParameters: false); + + private static void ExportPublicKey(RSAParameters parameters, TextWriter outputStream) + { + if (parameters.Exponent == null || parameters.Modulus == null) + { + throw new ArgumentException($"{parameters} does not contain valid public key information"); + } + + using (var stream = new MemoryStream()) + { + var writer = new BinaryWriter(stream); + writer.Write((byte)0x30); // SEQUENCE + using (var innerStream = new MemoryStream()) + { + var innerWriter = new BinaryWriter(innerStream); + innerWriter.Write((byte)0x30); // SEQUENCE + EncodeLength(innerWriter, 13); + innerWriter.Write((byte)0x06); // OBJECT IDENTIFIER + var rsaEncryptionOid = new byte[] { 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01 }; + EncodeLength(innerWriter, rsaEncryptionOid.Length); + innerWriter.Write(rsaEncryptionOid); + innerWriter.Write((byte)0x05); // NULL + EncodeLength(innerWriter, 0); + innerWriter.Write((byte)0x03); // BIT STRING + using (var bitStringStream = new MemoryStream()) + { + var bitStringWriter = new BinaryWriter(bitStringStream); + bitStringWriter.Write((byte)0x00); // # of unused bits + bitStringWriter.Write((byte)0x30); // SEQUENCE + using (var paramsStream = new MemoryStream()) + { + var paramsWriter = new BinaryWriter(paramsStream); + EncodeIntegerBigEndian(paramsWriter, parameters.Modulus); // Modulus + EncodeIntegerBigEndian(paramsWriter, parameters.Exponent); // Exponent + var paramsLength = (int)paramsStream.Length; + EncodeLength(bitStringWriter, paramsLength); + bitStringWriter.Write(paramsStream.GetBuffer(), 0, paramsLength); + } + var bitStringLength = (int)bitStringStream.Length; + EncodeLength(innerWriter, bitStringLength); + innerWriter.Write(bitStringStream.GetBuffer(), 0, bitStringLength); + } + var length = (int)innerStream.Length; + EncodeLength(writer, length); + writer.Write(innerStream.GetBuffer(), 0, length); + } + + var base64 = Convert.ToBase64String(stream.GetBuffer(), 0, (int)stream.Length).ToCharArray(); + for (var i = 0; i < base64.Length; i += 64) + { + outputStream.Write(base64, i, Math.Min(64, base64.Length - i)); + } + } + } + + private static void EncodeLength(BinaryWriter stream, int length) + { + if (length < 0) throw new ArgumentOutOfRangeException(nameof(length), "Length must be non-negative"); + if (length < 0x80) + { + // Short form + stream.Write((byte)length); + } + else + { + // Long form + var temp = length; + var bytesRequired = 0; + while (temp > 0) + { + temp >>= 8; + bytesRequired++; + } + stream.Write((byte)(bytesRequired | 0x80)); + for (var i = bytesRequired - 1; i >= 0; i--) + { + stream.Write((byte)(length >> (8 * i) & 0xff)); + } + } + } + + private static void EncodeIntegerBigEndian(BinaryWriter stream, byte[] value, bool forceUnsigned = true) + { + stream.Write((byte)0x02); // INTEGER + var prefixZeros = 0; + for (var i = 0; i < value.Length; i++) + { + if (value[i] != 0) break; + prefixZeros++; + } + if (value.Length - prefixZeros == 0) + { + EncodeLength(stream, 1); + stream.Write((byte)0); + } + else + { + if (forceUnsigned && value[prefixZeros] > 0x7f) + { + // Add a prefix zero to force unsigned if the MSB is 1 + EncodeLength(stream, value.Length - prefixZeros + 1); + stream.Write((byte)0); + } + else + { + EncodeLength(stream, value.Length - prefixZeros); + } + for (var i = prefixZeros; i < value.Length; i++) + { + stream.Write(value[i]); + } + } + } +} diff --git a/src/WatchPrototype/HotReloadClient/Web/StaticWebAsset.cs b/src/WatchPrototype/HotReloadClient/Web/StaticWebAsset.cs new file mode 100644 index 00000000000..550f1b62555 --- /dev/null +++ b/src/WatchPrototype/HotReloadClient/Web/StaticWebAsset.cs @@ -0,0 +1,64 @@ +// 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.Immutable; +using System.IO; +using System.Threading.Tasks; + +namespace Microsoft.DotNet.HotReload; + +internal readonly struct StaticWebAsset(string filePath, string relativeUrl, string assemblyName, bool isApplicationProject) +{ + public string FilePath => filePath; + public string RelativeUrl => relativeUrl; + public string AssemblyName => assemblyName; + public bool IsApplicationProject => isApplicationProject; + + public const string WebRoot = "wwwroot"; + public const string ManifestFileName = "staticwebassets.development.json"; + + public static bool IsScopedCssFile(string filePath) + => filePath.EndsWith(".razor.css", StringComparison.Ordinal) || + filePath.EndsWith(".cshtml.css", StringComparison.Ordinal); + + public static string GetScopedCssRelativeUrl(string applicationProjectFilePath, string containingProjectFilePath) + => WebRoot + "/" + GetScopedCssBundleFileName(applicationProjectFilePath, containingProjectFilePath); + + public static string GetScopedCssBundleFileName(string applicationProjectFilePath, string containingProjectFilePath) + { + var sourceProjectName = Path.GetFileNameWithoutExtension(containingProjectFilePath); + + return string.Equals(containingProjectFilePath, applicationProjectFilePath, StringComparison.OrdinalIgnoreCase) + ? $"{sourceProjectName}.styles.css" + : $"{sourceProjectName}.bundle.scp.css"; + } + + public static bool IsScopedCssBundleFile(string filePath) + => filePath.EndsWith(".bundle.scp.css", StringComparison.Ordinal) || + filePath.EndsWith(".styles.css", StringComparison.Ordinal); + + public static bool IsCompressedAssetFile(string filePath) + => filePath.EndsWith(".gz", StringComparison.Ordinal); + + public static string? GetRelativeUrl(string applicationProjectFilePath, string containingProjectFilePath, string assetFilePath) + => IsScopedCssFile(assetFilePath) + ? GetScopedCssRelativeUrl(applicationProjectFilePath, containingProjectFilePath) + : GetAppRelativeUrlFomDiskPath(containingProjectFilePath, assetFilePath); + + /// + /// For non scoped css, the only static files which apply are the ones under the wwwroot folder in that project. The relative path + /// will always start with wwwroot. eg: "wwwroot/css/styles.css" + /// + public static string? GetAppRelativeUrlFomDiskPath(string containingProjectFilePath, string assetFilePath) + { + var webRoot = "wwwroot" + Path.DirectorySeparatorChar; + var webRootDir = Path.Combine(Path.GetDirectoryName(containingProjectFilePath)!, webRoot); + + return assetFilePath.StartsWith(webRootDir, StringComparison.OrdinalIgnoreCase) + ? assetFilePath.Substring(webRootDir.Length - webRoot.Length).Replace("\\", "/") + : null; + } +} diff --git a/src/WatchPrototype/HotReloadClient/Web/StaticWebAssetPattern.cs b/src/WatchPrototype/HotReloadClient/Web/StaticWebAssetPattern.cs new file mode 100644 index 00000000000..6e2bcf74226 --- /dev/null +++ b/src/WatchPrototype/HotReloadClient/Web/StaticWebAssetPattern.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. + +#nullable enable + +namespace Microsoft.DotNet.HotReload; + +internal sealed partial class StaticWebAssetPattern(string directory, string pattern, string baseUrl) +{ + public string Directory { get; } = directory; + public string Pattern { get; } = pattern; + public string BaseUrl { get; } = baseUrl; +} diff --git a/src/WatchPrototype/HotReloadClient/Web/StaticWebAssetsManifest.cs b/src/WatchPrototype/HotReloadClient/Web/StaticWebAssetsManifest.cs new file mode 100644 index 00000000000..22a1c4d5e0a --- /dev/null +++ b/src/WatchPrototype/HotReloadClient/Web/StaticWebAssetsManifest.cs @@ -0,0 +1,210 @@ +// 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.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; +using System.Text.Json; +using Microsoft.Extensions.Logging; + +namespace Microsoft.DotNet.HotReload; + +internal sealed class StaticWebAssetsManifest(ImmutableDictionary urlToPathMap, ImmutableArray discoveryPatterns) +{ + private static readonly JsonSerializerOptions s_options = new() + { + RespectNullableAnnotations = true, + }; + + private sealed class ManifestJson + { + public required List ContentRoots { get; init; } + public required ChildAssetJson Root { get; init; } + } + + private sealed class ChildAssetJson + { + public Dictionary? Children { get; init; } + public AssetInfoJson? Asset { get; init; } + public List? Patterns { get; init; } + } + + private sealed class AssetInfoJson + { + public required int ContentRootIndex { get; init; } + public required string SubPath { get; init; } + } + + private sealed class PatternJson + { + public required int ContentRootIndex { get; init; } + public required string Pattern { get; init; } + } + + /// + /// Maps relative URLs to file system paths. + /// + public ImmutableDictionary UrlToPathMap { get; } = urlToPathMap; + + /// + /// List of directory and search pattern pairs for discovering static web assets. + /// + public ImmutableArray DiscoveryPatterns { get; } = discoveryPatterns; + + public bool TryGetBundleFilePath(string bundleFileName, [NotNullWhen(true)] out string? filePath) + { + if (UrlToPathMap.TryGetValue(bundleFileName, out var bundleFilePath)) + { + filePath = bundleFilePath; + return true; + } + + foreach (var entry in UrlToPathMap) + { + var url = entry.Key; + var path = entry.Value; + + if (Path.GetFileName(path).Equals(bundleFileName, StringComparison.Ordinal)) + { + filePath = path; + return true; + } + } + + filePath = null; + return false; + } + + public static StaticWebAssetsManifest? TryParseFile(string path, ILogger logger) + { + Stream? stream; + + logger.LogDebug("Reading static web assets manifest file: '{FilePath}'.", path); + + try + { + stream = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); + } + catch (Exception e) + { + logger.LogError("Failed to read '{FilePath}': {Message}", path, e.Message); + return null; + } + + try + { + return TryParse(stream, path, logger); + } + finally + { + stream.Dispose(); + } + } + + /// The format is invalid. + public static StaticWebAssetsManifest? TryParse(Stream stream, string filePath, ILogger logger) + { + ManifestJson? manifest; + + try + { + manifest = JsonSerializer.Deserialize(stream, s_options); + } + catch (JsonException e) + { + logger.LogError("Failed to parse '{FilePath}': {Message}", filePath, e.Message); + return null; + } + + if (manifest == null) + { + logger.LogError("Failed to parse '{FilePath}'", filePath); + return null; + } + + var validContentRoots = new string?[manifest.ContentRoots.Count]; + + for (var i = 0; i < validContentRoots.Length; i++) + { + var root = manifest.ContentRoots[i]; + if (Path.IsPathFullyQualified(root)) + { + validContentRoots[i] = root; + } + else + { + logger.LogWarning("Failed to parse '{FilePath}': ContentRoots path not fully qualified: {Root}", filePath, root); + } + } + + var urlToPathMap = ImmutableDictionary.CreateBuilder(); + var discoveryPatterns = ImmutableArray.CreateBuilder(); + + ProcessNode(manifest.Root, url: ""); + + return new StaticWebAssetsManifest(urlToPathMap.ToImmutable(), discoveryPatterns.ToImmutable()); + + void ProcessNode(ChildAssetJson node, string url) + { + if (node.Children != null) + { + foreach (var entry in node.Children) + { + var childName = entry.Key; + var child = entry.Value; + + ProcessNode(child, url: (url is []) ? childName : url + "/" + childName); + } + } + + if (node.Asset != null) + { + if (url == "") + { + logger.LogWarning("Failed to parse '{FilePath}': Asset has no URL", filePath); + return; + } + + if (!TryGetContentRoot(node.Asset.ContentRootIndex, out var root)) + { + return; + } + + urlToPathMap[url] = Path.Join(root, node.Asset.SubPath.Replace('/', Path.DirectorySeparatorChar)); + } + else if (node.Children == null) + { + logger.LogWarning("Failed to parse '{FilePath}': Missing Asset", filePath); + } + + if (node.Patterns != null) + { + foreach (var pattern in node.Patterns) + { + if (TryGetContentRoot(pattern.ContentRootIndex, out var root)) + { + discoveryPatterns.Add(new StaticWebAssetPattern(root, pattern.Pattern, url)); + } + } + } + + bool TryGetContentRoot(int index, [NotNullWhen(true)] out string? contentRoot) + { + if (index < 0 || index >= validContentRoots.Length) + { + logger.LogWarning("Failed to parse '{FilePath}': Invalid value of ContentRootIndex: {Value}", filePath, index); + contentRoot = null; + return false; + } + + contentRoot = validContentRoots[index]; + return contentRoot != null; + } + } + } +} diff --git a/src/WatchPrototype/HotReloadClient/Web/WebAssemblyHotReloadClient.cs b/src/WatchPrototype/HotReloadClient/Web/WebAssemblyHotReloadClient.cs new file mode 100644 index 00000000000..dee0f34ed35 --- /dev/null +++ b/src/WatchPrototype/HotReloadClient/Web/WebAssemblyHotReloadClient.cs @@ -0,0 +1,206 @@ +// 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.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace Microsoft.DotNet.HotReload +{ + internal sealed class WebAssemblyHotReloadClient( + ILogger logger, + ILogger agentLogger, + AbstractBrowserRefreshServer browserRefreshServer, + ImmutableArray projectHotReloadCapabilities, + Version projectTargetFrameworkVersion, + bool suppressBrowserRequestsForTesting) + : HotReloadClient(logger, agentLogger) + { + private static readonly ImmutableArray s_defaultCapabilities60 = + ["Baseline"]; + + private static readonly ImmutableArray s_defaultCapabilities70 = + ["Baseline", "AddMethodToExistingType", "AddStaticFieldToExistingType", "NewTypeDefinition", "ChangeCustomAttributes"]; + + private static readonly ImmutableArray s_defaultCapabilities80 = + ["Baseline", "AddMethodToExistingType", "AddStaticFieldToExistingType", "NewTypeDefinition", "ChangeCustomAttributes", + "AddInstanceFieldToExistingType", "GenericAddMethodToExistingType", "GenericUpdateMethod", "UpdateParameters", "GenericAddFieldToExistingType"]; + + private static readonly ImmutableArray s_defaultCapabilities90 = + s_defaultCapabilities80; + + private readonly ImmutableArray _capabilities = GetUpdateCapabilities(logger, projectHotReloadCapabilities, projectTargetFrameworkVersion); + + private static ImmutableArray GetUpdateCapabilities(ILogger logger, ImmutableArray projectHotReloadCapabilities, Version projectTargetFrameworkVersion) + { + var capabilities = projectHotReloadCapabilities.IsEmpty + ? projectTargetFrameworkVersion.Major switch + { + 9 => s_defaultCapabilities90, + 8 => s_defaultCapabilities80, + 7 => s_defaultCapabilities70, + 6 => s_defaultCapabilities60, + _ => [], + } + : projectHotReloadCapabilities; + + if (capabilities is not []) + { + capabilities = AddImplicitCapabilities(capabilities); + } + + var capabilitiesStr = string.Join(", ", capabilities); + if (projectHotReloadCapabilities.IsEmpty) + { + logger.LogDebug("Project specifies capabilities: {Capabilities}.", capabilitiesStr); + } + else + { + logger.LogDebug("Using capabilities based on project target framework version: '{Version}': {Capabilities}.", projectTargetFrameworkVersion, capabilitiesStr); + } + + return capabilities; + } + + public override void Dispose() + { + // Do nothing. + } + + public override void ConfigureLaunchEnvironment(IDictionary environmentBuilder) + { + // the environment is configued via browser refesh server + } + + public override void InitiateConnection(CancellationToken cancellationToken) + { + } + + public override async Task WaitForConnectionEstablishedAsync(CancellationToken cancellationToken) + // Wait for the browser connection to be established. Currently we need the browser to be running in order to apply changes. + => await browserRefreshServer.WaitForClientConnectionAsync(cancellationToken); + + public override Task> GetUpdateCapabilitiesAsync(CancellationToken cancellationToken) + => Task.FromResult(_capabilities); + + public override async Task> ApplyManagedCodeUpdatesAsync(ImmutableArray updates, CancellationToken applyOperationCancellationToken, CancellationToken cancellationToken) + { + var applicableUpdates = await FilterApplicableUpdatesAsync(updates, cancellationToken); + if (applicableUpdates.Count == 0) + { + return Task.FromResult(true); + } + + // When testing abstract away the browser and pretend all changes have been applied: + if (suppressBrowserRequestsForTesting) + { + return Task.FromResult(true); + } + + // Make sure to send the same update to all browsers, the only difference is the shared secret. + var deltas = updates.Select(static update => new JsonDelta + { + ModuleId = update.ModuleId, + MetadataDelta = ImmutableCollectionsMarshal.AsArray(update.MetadataDelta)!, + ILDelta = ImmutableCollectionsMarshal.AsArray(update.ILDelta)!, + PdbDelta = ImmutableCollectionsMarshal.AsArray(update.PdbDelta)!, + UpdatedTypes = ImmutableCollectionsMarshal.AsArray(update.UpdatedTypes)!, + }).ToArray(); + + var loggingLevel = Logger.IsEnabled(LogLevel.Debug) ? ResponseLoggingLevel.Verbose : ResponseLoggingLevel.WarningsAndErrors; + + // If no browser is connected we assume the changes have been applied. + // If at least one browser suceeds we consider the changes successfully applied. + // TODO: + // The refresh server should remember the deltas and apply them to browsers connected in future. + // Currently the changes are remembered on the dev server and sent over there from the browser. + // If no browser is connected the changes are not sent though. + + return QueueUpdateBatch( + sendAndReceive: async batchId => + { + var result = await browserRefreshServer.SendAndReceiveAsync( + request: sharedSecret => new JsonApplyManagedCodeUpdatesRequest + { + SharedSecret = sharedSecret, + UpdateId = batchId, + Deltas = deltas, + ResponseLoggingLevel = (int)loggingLevel + }, + response: new ResponseFunc((value, logger) => + { + var success = ReceiveUpdateResponse(value, logger); + Logger.Log(success ? LogEvents.UpdateBatchCompleted : LogEvents.UpdateBatchFailed, batchId); + return success; + }), + applyOperationCancellationToken); + + return result ?? false; + }, + applyOperationCancellationToken); + } + + public override Task> ApplyStaticAssetUpdatesAsync(ImmutableArray updates, CancellationToken applyOperationCancellationToken, CancellationToken cancellationToken) + // static asset updates are handled by browser refresh server: + => Task.FromResult(Task.FromResult(true)); + + private static bool ReceiveUpdateResponse(ReadOnlySpan value, ILogger logger) + { + var data = AbstractBrowserRefreshServer.DeserializeJson(value); + + foreach (var entry in data.Log) + { + ReportLogEntry(logger, entry.Message, (AgentMessageSeverity)entry.Severity); + } + + return data.Success; + } + + public override Task InitialUpdatesAppliedAsync(CancellationToken cancellationToken) + => Task.CompletedTask; + + private readonly struct JsonApplyManagedCodeUpdatesRequest + { + public string Type => "ApplyManagedCodeUpdates"; + public string? SharedSecret { get; init; } + + public int UpdateId { get; init; } + public JsonDelta[] Deltas { get; init; } + public int ResponseLoggingLevel { get; init; } + } + + private readonly struct JsonDelta + { + public Guid ModuleId { get; init; } + public byte[] MetadataDelta { get; init; } + public byte[] ILDelta { get; init; } + public byte[] PdbDelta { get; init; } + public int[] UpdatedTypes { get; init; } + } + + private readonly struct JsonApplyDeltasResponse + { + public bool Success { get; init; } + public IEnumerable Log { get; init; } + } + + private readonly struct JsonLogEntry + { + public string Message { get; init; } + public int Severity { get; init; } + } + + private readonly struct JsonGetApplyUpdateCapabilitiesRequest + { + public string Type => "GetApplyUpdateCapabilities"; + } + } +} diff --git a/src/WatchPrototype/HotReloadClient/Web/WebServerHost.cs b/src/WatchPrototype/HotReloadClient/Web/WebServerHost.cs new file mode 100644 index 00000000000..e89a00c6513 --- /dev/null +++ b/src/WatchPrototype/HotReloadClient/Web/WebServerHost.cs @@ -0,0 +1,21 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable enable + +using System; +using System.Collections.Immutable; + +namespace Microsoft.DotNet.HotReload; + +internal sealed class WebServerHost(IDisposable listener, ImmutableArray endPoints, string virtualDirectory) : IDisposable +{ + public ImmutableArray EndPoints + => endPoints; + + public string VirtualDirectory + => virtualDirectory; + + public void Dispose() + => listener.Dispose(); +} diff --git a/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/.editorconfig b/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/.editorconfig new file mode 100644 index 00000000000..5aebb538031 --- /dev/null +++ b/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/.editorconfig @@ -0,0 +1,20 @@ +# Note: this editorconfig is *only* used during local builds. +# The actual source package will include 'eng\config\SourcePackage.editorconfig' or similar per-TFM config to control analyzer behavior at the consumption side. + +[*.cs] +# IDE0240: Remove redundant nullable directive +# The directive needs to be included since all sources in a source package are considered generated code +# when referenced from a project via package reference. +dotnet_diagnostic.IDE0240.severity = none + +dotnet_diagnostic.RS0051.severity = warning # Add internal types and members to the declared API +dotnet_diagnostic.RS0052.severity = warning # Remove deleted types and members from the declared internal API +dotnet_diagnostic.RS0053.severity = warning # The contents of the internal API files are invalid +dotnet_diagnostic.RS0054.severity = warning # Do not duplicate symbols in internal API files +dotnet_diagnostic.RS0055.severity = warning # Annotate nullability of internal types and members in the declared API +dotnet_diagnostic.RS0056.severity = warning # Enable tracking of nullability of reference types in the declared API +dotnet_diagnostic.RS0057.severity = warning # Internal members should not use oblivious types +dotnet_diagnostic.RS0058.severity = warning # Missing shipped or unshipped internal API file +dotnet_diagnostic.RS0059.severity = warning # Do not add multiple public overloads with optional parameters +dotnet_diagnostic.RS0060.severity = warning # API with optional parameter(s) should have the most parameters amongst its public overloads +dotnet_diagnostic.RS0061.severity = warning # Constructor make noninheritable base class inheritable diff --git a/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/ExternalHelpers.cs b/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/ExternalHelpers.cs new file mode 100644 index 00000000000..01df80caf68 --- /dev/null +++ b/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/ExternalHelpers.cs @@ -0,0 +1,42 @@ +// 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.FileBasedPrograms; + +/// +/// When targeting netstandard2.0, the user of the source package must "implement" certain methods by declaring members in this type. +/// +internal partial class ExternalHelpers +{ + public static partial int CombineHashCodes(int value1, int value2); + public static partial string GetRelativePath(string relativeTo, string path); + + public static partial bool IsPathFullyQualified(string path); + +#if NET + public static partial int CombineHashCodes(int value1, int value2) + => HashCode.Combine(value1, value2); + + public static partial string GetRelativePath(string relativeTo, string path) + => Path.GetRelativePath(relativeTo, path); + + public static partial bool IsPathFullyQualified(string path) + => Path.IsPathFullyQualified(path); + +#elif FILE_BASED_PROGRAMS_SOURCE_PACKAGE_BUILD + // This path should only be used when we are verifying that the source package itself builds under netstandard2.0. + public static partial int CombineHashCodes(int value1, int value2) + => throw new NotImplementedException(); + + public static partial string GetRelativePath(string relativeTo, string path) + => throw new NotImplementedException(); + + public static partial bool IsPathFullyQualified(string path) + => throw new NotImplementedException(); + +#endif +} diff --git a/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/FileBasedProgramsResources.resx b/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/FileBasedProgramsResources.resx new file mode 100644 index 00000000000..0af28bb5fd1 --- /dev/null +++ b/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/FileBasedProgramsResources.resx @@ -0,0 +1,175 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Could not find any project in `{0}`. + + + Could not find project or directory `{0}`. + + + Found more than one project in `{0}`. Specify which one to use. + + + Invalid property name: {0} + {0} is an inner exception message. + + + The property directive needs to have two parts separated by '=' like '#:property PropertyName=PropertyValue'. + {Locked="#:property"} + + + Static graph restore is not supported for file-based apps. Remove the '#:property'. + {Locked="#:property"} + + + error + Used when reporting directive errors like "file(location): error: message". + + + The directive should contain a name without special characters and an optional value separated by '{1}' like '#:{0} Name{1}Value'. + {0} is the directive type like 'package' or 'sdk'. {1} is the expected separator like '@' or '='. + + + Some directives cannot be converted. Run the file to see all compilation errors. Specify '--force' to convert anyway. + {Locked="--force"} + + + Duplicate directives are not supported: {0} + {0} is the directive type and name. + + + Directives currently cannot contain double quotes ("). + + + The '#:project' directive is invalid: {0} + {0} is the inner error message. + + + Missing name of '{0}'. + {0} is the directive name like 'package' or 'sdk'. + + + 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. + + diff --git a/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/FileLevelDirectiveHelpers.cs b/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/FileLevelDirectiveHelpers.cs new file mode 100644 index 00000000000..681843c6cbc --- /dev/null +++ b/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/FileLevelDirectiveHelpers.cs @@ -0,0 +1,643 @@ +// 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.Collections.Immutable; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; +using System.Xml; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using Microsoft.DotNet.ProjectTools; + +namespace Microsoft.DotNet.FileBasedPrograms; + +internal static class FileLevelDirectiveHelpers +{ + public static SyntaxTokenParser CreateTokenizer(SourceText text) + { + return SyntaxFactory.CreateTokenParser(text, + CSharpParseOptions.Default.WithFeatures([new("FileBasedProgram", "true")])); + } + + /// + /// If , the whole is parsed to find diagnostics about every app directive. + /// Otherwise, only directives up to the first C# token is checked. + /// The former is useful for dotnet project convert where we want to report all errors because it would be difficult to fix them up after the conversion. + /// 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) + { + var builder = ImmutableArray.CreateBuilder(); + var tokenizer = CreateTokenizer(sourceFile.Text); + + var result = tokenizer.ParseLeadingTrivia(); + var triviaList = result.Token.LeadingTrivia; + + FindLeadingDirectives(sourceFile, triviaList, reportError, 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. + if (reportAllErrors) + { + tokenizer.ResetTo(result); + + do + { + result = tokenizer.ParseNextToken(); + + foreach (var trivia in result.Token.LeadingTrivia) + { + ReportErrorFor(trivia); + } + + foreach (var trivia in result.Token.TrailingTrivia) + { + ReportErrorFor(trivia); + } + } + while (!result.Token.IsKind(SyntaxKind.EndOfFileToken)); + } + + void ReportErrorFor(SyntaxTrivia trivia) + { + if (trivia.ContainsDiagnostics && trivia.IsKind(SyntaxKind.IgnoredDirectiveTrivia)) + { + reportError(sourceFile, trivia.Span, FileBasedProgramsResources.CannotConvertDirective); + } + } + + // The result should be ordered by source location, RemoveDirectivesFromFile depends on that. + return builder.ToImmutable(); + } + + /// Finds file-level directives in the leading trivia list of a compilation unit and reports diagnostics on them. + /// The builder to store the parsed directives in, or null if the parsed directives are not needed. + public static void FindLeadingDirectives( + SourceFile sourceFile, + SyntaxTriviaList triviaList, + ErrorReporter reportError, + ImmutableArray.Builder? builder) + { + Debug.Assert(triviaList.Span.Start == 0); + + var deduplicated = new Dictionary(NamedDirectiveComparer.Instance); + TextSpan previousWhiteSpaceSpan = default; + + for (var index = 0; index < triviaList.Count; index++) + { + var trivia = triviaList[index]; + // Stop when the trivia contains an error (e.g., because it's after #if). + if (trivia.ContainsDiagnostics) + { + break; + } + + if (trivia.IsKind(SyntaxKind.WhitespaceTrivia)) + { + Debug.Assert(previousWhiteSpaceSpan.IsEmpty); + previousWhiteSpaceSpan = trivia.FullSpan; + continue; + } + + if (trivia.IsKind(SyntaxKind.ShebangDirectiveTrivia)) + { + TextSpan span = GetFullSpan(previousWhiteSpaceSpan, trivia); + + var whiteSpace = GetWhiteSpaceInfo(triviaList, index); + var info = new CSharpDirective.ParseInfo + { + Span = span, + LeadingWhiteSpace = whiteSpace.Leading, + TrailingWhiteSpace = whiteSpace.Trailing, + }; + builder?.Add(new CSharpDirective.Shebang(info)); + } + else if (trivia.IsKind(SyntaxKind.IgnoredDirectiveTrivia)) + { + TextSpan span = GetFullSpan(previousWhiteSpaceSpan, trivia); + + var message = trivia.GetStructure() is IgnoredDirectiveTriviaSyntax { Content: { RawKind: (int)SyntaxKind.StringLiteralToken } content } + ? content.Text.AsSpan().Trim() + : ""; + var parts = Patterns.Whitespace.Split(message.ToString(), 2); + var name = parts.Length > 0 ? parts[0] : ""; + var value = parts.Length > 1 ? parts[1] : ""; + Debug.Assert(!(parts.Length > 2)); + + var whiteSpace = GetWhiteSpaceInfo(triviaList, index); + var context = new CSharpDirective.ParseContext + { + Info = new() + { + Span = span, + LeadingWhiteSpace = whiteSpace.Leading, + TrailingWhiteSpace = whiteSpace.Trailing, + }, + ReportError = reportError, + SourceFile = sourceFile, + DirectiveKind = name, + DirectiveText = value, + }; + + // 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); + } + + if (CSharpDirective.Parse(context) is { } directive) + { + // If the directive is already present, report an error. + 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)); + } + else + { + deduplicated.Add(directive, directive); + } + + builder?.Add(directive); + } + } + + previousWhiteSpaceSpan = default; + } + + return; + + static TextSpan GetFullSpan(TextSpan previousWhiteSpaceSpan, SyntaxTrivia trivia) + { + // Include the preceding whitespace in the span, i.e., span will be the whole line. + return previousWhiteSpaceSpan.IsEmpty ? trivia.FullSpan : TextSpan.FromBounds(previousWhiteSpaceSpan.Start, trivia.FullSpan.End); + } + + static (WhiteSpaceInfo Leading, WhiteSpaceInfo Trailing) GetWhiteSpaceInfo(in SyntaxTriviaList triviaList, int index) + { + (WhiteSpaceInfo Leading, WhiteSpaceInfo Trailing) result = default; + + for (int i = index - 1; i >= 0; i--) + { + if (!Fill(ref result.Leading, triviaList, i)) break; + } + + for (int i = index + 1; i < triviaList.Count; i++) + { + if (!Fill(ref result.Trailing, triviaList, i)) break; + } + + return result; + + static bool Fill(ref WhiteSpaceInfo info, in SyntaxTriviaList triviaList, int index) + { + var trivia = triviaList[index]; + if (trivia.IsKind(SyntaxKind.EndOfLineTrivia)) + { + info.LineBreaks += 1; + info.TotalLength += trivia.FullSpan.Length; + return true; + } + + if (trivia.IsKind(SyntaxKind.WhitespaceTrivia)) + { + info.TotalLength += trivia.FullSpan.Length; + return true; + } + + return false; + } + } + } +} + +internal readonly record struct SourceFile(string Path, SourceText Text) +{ + public static SourceFile Load(string filePath) + { + using var stream = File.OpenRead(filePath); + // Let SourceText.From auto-detect the encoding (including BOM detection) + 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); + // Use the encoding from SourceText, which preserves the original BOM state + var encoding = Text.Encoding ?? new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + using var writer = new StreamWriter(stream, encoding); + 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})"; + } +} + +internal static partial class Patterns +{ + public static Regex Whitespace { get; } = new Regex("""\s+""", RegexOptions.Compiled); + + public static Regex DisallowedNameCharacters { get; } = new Regex("""[\s@=/]""", RegexOptions.Compiled); + + public static Regex EscapedCompilerOption { get; } = new Regex("""^/\w+:".*"$""", RegexOptions.Compiled | RegexOptions.Singleline); +} + +internal struct WhiteSpaceInfo +{ + public int LineBreaks; + public int TotalLength; +} + +/// +/// Represents a C# directive starting with #: (a.k.a., "file-level directive"). +/// Those are ignored by the language but recognized by us. +/// +internal abstract class CSharpDirective(in CSharpDirective.ParseInfo info) +{ + public ParseInfo Info { get; } = info; + + public readonly struct ParseInfo + { + /// + /// Span of the full line including the trailing line break. + /// + public required TextSpan Span { get; init; } + public required WhiteSpaceInfo LeadingWhiteSpace { get; init; } + 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 string DirectiveKind { get; init; } + public required string DirectiveText { get; init; } + } + + public static Named? Parse(in ParseContext context) + { + switch (context.DirectiveKind) + { + case "sdk": return Sdk.Parse(context); + case "property": return Property.Parse(context); + case "package": return Package.Parse(context); + case "project": return Project.Parse(context); + default: + context.ReportError(context.SourceFile, context.Info.Span, string.Format(FileBasedProgramsResources.UnrecognizedDirective, context.DirectiveKind)); + return null; + }; + } + + private static (string, string?)? ParseOptionalTwoParts(in ParseContext context, char separator) + { + var separatorIndex = context.DirectiveText.IndexOf(separator); + var firstPart = (separatorIndex < 0 ? context.DirectiveText : context.DirectiveText.AsSpan(0, separatorIndex)).TrimEnd(); + + string directiveKind = context.DirectiveKind; + if (firstPart.IsWhiteSpace()) + { + context.ReportError(context.SourceFile, context.Info.Span, 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)); + return null; + } + + if (separatorIndex < 0) + { + return (firstPart.ToString(), null); + } + + var secondPart = context.DirectiveText.AsSpan(separatorIndex + 1).TrimStart(); + if (secondPart.IsWhiteSpace()) + { + Debug.Assert(secondPart.Length == 0, + "We have trimmed the second part, so if it's white space, it should be actually empty."); + + return (firstPart.ToString(), string.Empty); + } + + return (firstPart.ToString(), secondPart.ToString()); + } + + public abstract override string ToString(); + + /// + /// #! directive. + /// + public sealed class Shebang(in ParseInfo info) : CSharpDirective(info) + { + public override string ToString() => "#!"; + } + + public abstract class Named(in ParseInfo info) : CSharpDirective(info) + { + public required string Name { get; init; } + } + + /// + /// #:sdk directive. + /// + public sealed class Sdk(in ParseInfo info) : Named(info) + { + public string? Version { get; init; } + + public static new Sdk? Parse(in ParseContext context) + { + if (ParseOptionalTwoParts(context, separator: '@') is not var (sdkName, sdkVersion)) + { + return null; + } + + return new Sdk(context.Info) + { + Name = sdkName, + Version = sdkVersion, + }; + } + + public override string ToString() => Version is null ? $"#:sdk {Name}" : $"#:sdk {Name}@{Version}"; + } + + /// + /// #:property directive. + /// + public sealed class Property(in ParseInfo info) : Named(info) + { + public required string Value { get; init; } + + public static new Property? Parse(in ParseContext context) + { + if (ParseOptionalTwoParts(context, separator: '=') is not var (propertyName, propertyValue)) + { + return null; + } + + if (propertyValue is null) + { + context.ReportError(context.SourceFile, context.Info.Span, FileBasedProgramsResources.PropertyDirectiveMissingParts); + return null; + } + + try + { + propertyName = XmlConvert.VerifyName(propertyName); + } + catch (XmlException ex) + { + context.ReportError(context.SourceFile, context.Info.Span, 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); + } + + return new Property(context.Info) + { + Name = propertyName, + Value = propertyValue, + }; + } + + public override string ToString() => $"#:property {Name}={Value}"; + } + + /// + /// #:package directive. + /// + public sealed class Package(in ParseInfo info) : Named(info) + { + public string? Version { get; init; } + + public static new Package? Parse(in ParseContext context) + { + if (ParseOptionalTwoParts(context, separator: '@') is not var (packageName, packageVersion)) + { + return null; + } + + return new Package(context.Info) + { + Name = packageName, + Version = packageVersion, + }; + } + + public override string ToString() => Version is null ? $"#:package {Name}" : $"#:package {Name}@{Version}"; + } + + /// + /// #:project directive. + /// + public sealed class Project : Named + { + [SetsRequiredMembers] + public Project(in ParseInfo info, string name) : base(info) + { + Name = name; + OriginalName = name; + } + + /// + /// Preserved across calls, i.e., + /// this is the original directive text as entered by the user. + /// + public string OriginalName { get; init; } + + /// + /// This is the with MSBuild $(..) vars expanded. + /// E.g. The expansion might be implemented via ProjectInstance.ExpandString. + /// + public string? ExpandedName { get; init; } + + /// + /// This is the resolved via + /// (i.e., this is a file path if the original text pointed to a directory). + /// + public string? ProjectFilePath { get; init; } + + public static new Project? Parse(in ParseContext context) + { + var directiveText = context.DirectiveText; + if (directiveText.IsWhiteSpace()) + { + string directiveKind = context.DirectiveKind; + context.ReportError(context.SourceFile, context.Info.Span, string.Format(FileBasedProgramsResources.MissingDirectiveName, directiveKind)); + return null; + } + + return new Project(context.Info, directiveText); + } + + public enum NameKind + { + /// + /// Change and . + /// + Expanded = 1, + + /// + /// Change and . + /// + ProjectFilePath = 2, + + /// + /// Change only . + /// + Final = 3, + } + + public Project WithName(string name, NameKind kind) + { + return new Project(Info, name) + { + OriginalName = OriginalName, + ExpandedName = kind == NameKind.Expanded ? name : ExpandedName, + ProjectFilePath = kind == NameKind.ProjectFilePath ? name : ProjectFilePath, + }; + } + + /// + /// If the directive points to a directory, returns a new directive pointing to the corresponding project file. + /// + public Project EnsureProjectFilePath(SourceFile sourceFile, ErrorReporter reportError) + { + var resolvedName = Name; + + // 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 resolvedProjectPath = Path.Combine(sourceDirectory, resolvedName.Replace('\\', '/')); + if (Directory.Exists(resolvedProjectPath)) + { + if (ProjectLocator.TryGetProjectFileFromDirectory(resolvedProjectPath, out var projectFilePath, out var error)) + { + // Keep a relative path only if the original directive was a relative path. + resolvedName = ExternalHelpers.IsPathFullyQualified(resolvedName) + ? projectFilePath + : ExternalHelpers.GetRelativePath(relativeTo: sourceDirectory, projectFilePath); + } + else + { + reportError(sourceFile, Info.Span, string.Format(FileBasedProgramsResources.InvalidProjectDirective, error)); + } + } + else if (!File.Exists(resolvedProjectPath)) + { + reportError(sourceFile, Info.Span, + string.Format(FileBasedProgramsResources.InvalidProjectDirective, string.Format(FileBasedProgramsResources.CouldNotFindProjectOrDirectory, resolvedProjectPath))); + } + + return WithName(resolvedName, NameKind.ProjectFilePath); + } + + public override string ToString() => $"#:project {Name}"; + } +} + +/// +/// Used for deduplication - compares directives by their type and name (ignoring case). +/// +internal sealed class NamedDirectiveComparer : IEqualityComparer +{ + public static readonly NamedDirectiveComparer Instance = new(); + + private NamedDirectiveComparer() { } + + public bool Equals(CSharpDirective.Named? x, CSharpDirective.Named? y) + { + if (ReferenceEquals(x, y)) return true; + + if (x is null || y is null) return false; + + return x.GetType() == y.GetType() && + StringComparer.OrdinalIgnoreCase.Equals(x.Name, y.Name); + } + + public int GetHashCode(CSharpDirective.Named obj) + { + return ExternalHelpers.CombineHashCodes( + obj.GetType().GetHashCode(), + StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Name)); + } +} + +internal sealed class SimpleDiagnostic +{ + public required Position Location { get; init; } + public required string Message { get; init; } + + /// + /// An adapter of that ensures we JSON-serialize only the necessary fields. + /// + /// + /// note: this type is only serialized for run-api scenarios. + /// If/when run-api is removed, we would also want to remove the usage of System.Text.Json attributes. + /// + public readonly struct Position + { + public required string Path { get; init; } + public required LinePositionSpan Span { get; init; } + [JsonIgnore] + public TextSpan TextSpan { get; init; } + } +} + +internal delegate void ErrorReporter(SourceFile sourceFile, TextSpan textSpan, string message); + +internal static partial class ErrorReporters +{ + public static readonly ErrorReporter IgnoringReporter = + static (_, _, _) => { }; + + public static ErrorReporter CreateCollectingReporter(out ImmutableArray.Builder builder) + { + var capturedBuilder = builder = ImmutableArray.CreateBuilder(); + + return (sourceFile, textSpan, message) => + capturedBuilder.Add(new SimpleDiagnostic + { + Location = new SimpleDiagnostic.Position() + { + Path = sourceFile.Path, + TextSpan = textSpan, + Span = sourceFile.GetFileLinePositionSpan(textSpan).Span + }, + Message = message + }); + } +} diff --git a/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/InternalAPI.Shipped.txt b/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/InternalAPI.Shipped.txt new file mode 100644 index 00000000000..7dc5c58110b --- /dev/null +++ b/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/InternalAPI.Shipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/InternalAPI.Unshipped.txt b/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/InternalAPI.Unshipped.txt new file mode 100644 index 00000000000..9376a191aa0 --- /dev/null +++ b/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/InternalAPI.Unshipped.txt @@ -0,0 +1,131 @@ +Microsoft.DotNet.FileBasedPrograms.CSharpDirective +Microsoft.DotNet.FileBasedPrograms.CSharpDirective.CSharpDirective(in Microsoft.DotNet.FileBasedPrograms.CSharpDirective.ParseInfo info) -> void +Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Info.get -> Microsoft.DotNet.FileBasedPrograms.CSharpDirective.ParseInfo +Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Named +Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Named.Name.get -> string! +Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Named.Name.init -> void +Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Named.Named(in Microsoft.DotNet.FileBasedPrograms.CSharpDirective.ParseInfo info) -> void +Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Package +Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Package.Package(in Microsoft.DotNet.FileBasedPrograms.CSharpDirective.ParseInfo info) -> void +Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Package.Version.get -> string? +Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Package.Version.init -> void +Microsoft.DotNet.FileBasedPrograms.CSharpDirective.ParseContext +Microsoft.DotNet.FileBasedPrograms.CSharpDirective.ParseContext.DirectiveKind.get -> string! +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.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.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.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.ExpandedName.get -> string? +Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project.ExpandedName.init -> void +Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project.NameKind +Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project.NameKind.Expanded = 1 -> Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project.NameKind +Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project.NameKind.Final = 3 -> Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project.NameKind +Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project.NameKind.ProjectFilePath = 2 -> Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project.NameKind +Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project.OriginalName.get -> string! +Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project.OriginalName.init -> void +Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project.Project(in Microsoft.DotNet.FileBasedPrograms.CSharpDirective.ParseInfo info, string! name) -> void +Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project.ProjectFilePath.get -> string? +Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project.ProjectFilePath.init -> void +Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project.WithName(string! name, Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project.NameKind kind) -> Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project! +Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Property +Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Property.Property(in Microsoft.DotNet.FileBasedPrograms.CSharpDirective.ParseInfo info) -> void +Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Property.Value.get -> string! +Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Property.Value.init -> void +Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Sdk +Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Sdk.Sdk(in Microsoft.DotNet.FileBasedPrograms.CSharpDirective.ParseInfo info) -> void +Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Sdk.Version.get -> string? +Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Sdk.Version.init -> void +Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Shebang +Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Shebang.Shebang(in Microsoft.DotNet.FileBasedPrograms.CSharpDirective.ParseInfo info) -> void +Microsoft.DotNet.FileBasedPrograms.ErrorReporter +Microsoft.DotNet.FileBasedPrograms.ErrorReporters +Microsoft.DotNet.FileBasedPrograms.ExternalHelpers +Microsoft.DotNet.FileBasedPrograms.ExternalHelpers.ExternalHelpers() -> void +Microsoft.DotNet.FileBasedPrograms.FileLevelDirectiveHelpers +Microsoft.DotNet.FileBasedPrograms.MSBuildUtilities +Microsoft.DotNet.FileBasedPrograms.MSBuildUtilities.MSBuildUtilities() -> void +Microsoft.DotNet.FileBasedPrograms.NamedDirectiveComparer +Microsoft.DotNet.FileBasedPrograms.NamedDirectiveComparer.Equals(Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Named? x, Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Named? y) -> bool +Microsoft.DotNet.FileBasedPrograms.NamedDirectiveComparer.GetHashCode(Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Named! obj) -> int +Microsoft.DotNet.FileBasedPrograms.Patterns +Microsoft.DotNet.FileBasedPrograms.SimpleDiagnostic +Microsoft.DotNet.FileBasedPrograms.SimpleDiagnostic.Location.get -> Microsoft.DotNet.FileBasedPrograms.SimpleDiagnostic.Position +Microsoft.DotNet.FileBasedPrograms.SimpleDiagnostic.Location.init -> void +Microsoft.DotNet.FileBasedPrograms.SimpleDiagnostic.Message.get -> string! +Microsoft.DotNet.FileBasedPrograms.SimpleDiagnostic.Message.init -> void +Microsoft.DotNet.FileBasedPrograms.SimpleDiagnostic.Position +Microsoft.DotNet.FileBasedPrograms.SimpleDiagnostic.Position.Path.get -> string! +Microsoft.DotNet.FileBasedPrograms.SimpleDiagnostic.Position.Path.init -> void +Microsoft.DotNet.FileBasedPrograms.SimpleDiagnostic.Position.Position() -> void +Microsoft.DotNet.FileBasedPrograms.SimpleDiagnostic.Position.Span.get -> Microsoft.CodeAnalysis.Text.LinePositionSpan +Microsoft.DotNet.FileBasedPrograms.SimpleDiagnostic.Position.Span.init -> void +Microsoft.DotNet.FileBasedPrograms.SimpleDiagnostic.Position.TextSpan.get -> Microsoft.CodeAnalysis.Text.TextSpan +Microsoft.DotNet.FileBasedPrograms.SimpleDiagnostic.Position.TextSpan.init -> void +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 +Microsoft.DotNet.FileBasedPrograms.SourceFile.Save() -> void +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.WhiteSpaceInfo() -> void +Microsoft.DotNet.ProjectTools.ProjectLocator +override abstract Microsoft.DotNet.FileBasedPrograms.CSharpDirective.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.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? +static Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Property.Parse(in Microsoft.DotNet.FileBasedPrograms.CSharpDirective.ParseContext context) -> Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Property? +static Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Sdk.Parse(in Microsoft.DotNet.FileBasedPrograms.CSharpDirective.ParseContext context) -> Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Sdk? +static Microsoft.DotNet.FileBasedPrograms.ErrorReporters.CreateCollectingReporter(out System.Collections.Immutable.ImmutableArray.Builder! builder) -> Microsoft.DotNet.FileBasedPrograms.ErrorReporter! +static Microsoft.DotNet.FileBasedPrograms.ExternalHelpers.CombineHashCodes(int value1, int value2) -> int +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.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! +static Microsoft.DotNet.FileBasedPrograms.Patterns.Whitespace.get -> System.Text.RegularExpressions.Regex! +static Microsoft.DotNet.FileBasedPrograms.SourceFile.Load(string! filePath) -> Microsoft.DotNet.FileBasedPrograms.SourceFile +static Microsoft.DotNet.FileBasedPrograms.SourceFile.operator !=(Microsoft.DotNet.FileBasedPrograms.SourceFile left, Microsoft.DotNet.FileBasedPrograms.SourceFile right) -> bool +static Microsoft.DotNet.FileBasedPrograms.SourceFile.operator ==(Microsoft.DotNet.FileBasedPrograms.SourceFile left, Microsoft.DotNet.FileBasedPrograms.SourceFile right) -> bool +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 +~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/MSBuildUtilities.cs b/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/MSBuildUtilities.cs new file mode 100644 index 00000000000..0b556f86dae --- /dev/null +++ b/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/MSBuildUtilities.cs @@ -0,0 +1,71 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +// https://github.com/dotnet/sdk/issues/51487: avoid this extra copy of the file. +#nullable enable +using System; + +namespace Microsoft.DotNet.FileBasedPrograms +{ + /// + /// Internal utilities copied from microsoft/MSBuild repo. + /// + class MSBuildUtilities + { + /// + /// Converts a string to a bool. We consider "true/false", "on/off", and + /// "yes/no" to be valid boolean representations in the XML. + /// Modified from its original version to not throw, but return a default value. + /// + /// The string to convert. + /// Boolean true or false, corresponding to the string. + internal static bool ConvertStringToBool(string? parameterValue, bool defaultValue = false) + { + if (string.IsNullOrEmpty(parameterValue)) + { + return defaultValue; + } + else if (ValidBooleanTrue(parameterValue)) + { + return true; + } + else if (ValidBooleanFalse(parameterValue)) + { + return false; + } + else + { + // Unsupported boolean representation. + return defaultValue; + } + } + + /// + /// Returns true if the string represents a valid MSBuild boolean true value, + /// such as "on", "!false", "yes" + /// + private static bool ValidBooleanTrue(string? parameterValue) + { + return ((string.Compare(parameterValue, "true", StringComparison.OrdinalIgnoreCase) == 0) || + (string.Compare(parameterValue, "on", StringComparison.OrdinalIgnoreCase) == 0) || + (string.Compare(parameterValue, "yes", StringComparison.OrdinalIgnoreCase) == 0) || + (string.Compare(parameterValue, "!false", StringComparison.OrdinalIgnoreCase) == 0) || + (string.Compare(parameterValue, "!off", StringComparison.OrdinalIgnoreCase) == 0) || + (string.Compare(parameterValue, "!no", StringComparison.OrdinalIgnoreCase) == 0)); + } + + /// + /// Returns true if the string represents a valid MSBuild boolean false value, + /// such as "!on" "off" "no" "!true" + /// + private static bool ValidBooleanFalse(string? parameterValue) + { + return ((string.Compare(parameterValue, "false", StringComparison.OrdinalIgnoreCase) == 0) || + (string.Compare(parameterValue, "off", StringComparison.OrdinalIgnoreCase) == 0) || + (string.Compare(parameterValue, "no", StringComparison.OrdinalIgnoreCase) == 0) || + (string.Compare(parameterValue, "!true", StringComparison.OrdinalIgnoreCase) == 0) || + (string.Compare(parameterValue, "!on", StringComparison.OrdinalIgnoreCase) == 0) || + (string.Compare(parameterValue, "!yes", StringComparison.OrdinalIgnoreCase) == 0)); + } + } +} diff --git a/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/Microsoft.DotNet.FileBasedPrograms.Package.csproj b/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/Microsoft.DotNet.FileBasedPrograms.Package.csproj new file mode 100644 index 00000000000..58804bce47a --- /dev/null +++ b/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/Microsoft.DotNet.FileBasedPrograms.Package.csproj @@ -0,0 +1,80 @@ + + + + + $(VisualStudioServiceTargetFramework);netstandard2.0 + false + none + false + preview + + + true + true + true + Microsoft.DotNet.FileBasedPrograms + false + Package containing sources for file-based programs support. + + $(NoWarn);NU5128 + false + $(DefineConstants);FILE_BASED_PROGRAMS_SOURCE_PACKAGE_BUILD + + disable + + + + + + + + + + + + + + + + + + + + + External\%(NuGetPackageId)\%(Link) + + + + + + + + + true + contentFiles\cs\any\FileBasedProgramsResources.resx + + + true + contentFiles\cs\any\xlf + + + + + + + + <_PackageFiles Remove="@(_PackageFiles)" Condition="$([System.String]::Copy('%(_PackageFiles.Identity)').EndsWith('FileBasedProgramsResources.cs'))" /> + + + + diff --git a/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/Microsoft.DotNet.FileBasedPrograms.projitems b/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/Microsoft.DotNet.FileBasedPrograms.projitems new file mode 100644 index 00000000000..4ee12260067 --- /dev/null +++ b/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/Microsoft.DotNet.FileBasedPrograms.projitems @@ -0,0 +1,17 @@ + + + + $(MSBuildAllProjects);$(MSBuildThisFileFullPath) + true + 374C251E-BF99-45B2-A58E-40229ED8AACA + + + Microsoft.DotNet.FileBasedPrograms + + + + + Designer + + + \ No newline at end of file diff --git a/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/Microsoft.DotNet.FileBasedPrograms.shproj b/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/Microsoft.DotNet.FileBasedPrograms.shproj new file mode 100644 index 00000000000..68cb2e509ef --- /dev/null +++ b/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/Microsoft.DotNet.FileBasedPrograms.shproj @@ -0,0 +1,13 @@ + + + + 374C251E-BF99-45B2-A58E-40229ED8AACA + 14.0 + + + + + + + + \ No newline at end of file diff --git a/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/ProjectLocator.cs b/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/ProjectLocator.cs new file mode 100644 index 00000000000..22817f3f679 --- /dev/null +++ b/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/ProjectLocator.cs @@ -0,0 +1,53 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable enable + +using System; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; +using Microsoft.DotNet.FileBasedPrograms; + +namespace Microsoft.DotNet.ProjectTools; + +internal static class ProjectLocator +{ + public static bool TryGetProjectFileFromDirectory(string projectDirectory, [NotNullWhen(true)] out string? projectFilePath, [NotNullWhen(false)] out string? error) + { + projectFilePath = null; + error = null; + + DirectoryInfo? dir; + try + { + dir = new DirectoryInfo(projectDirectory); + } + catch (ArgumentException) + { + dir = null; + } + + if (dir == null || !dir.Exists) + { + error = string.Format(FileBasedProgramsResources.CouldNotFindProjectOrDirectory, projectDirectory); + return false; + } + + FileInfo[] files = dir.GetFiles("*proj"); + if (files.Length == 0) + { + error = string.Format(FileBasedProgramsResources.CouldNotFindAnyProjectInDirectory, projectDirectory); + return false; + } + + if (files.Length > 1) + { + error = string.Format(FileBasedProgramsResources.MoreThanOneProjectInDirectory, projectDirectory); + return false; + } + + projectFilePath = files.First().FullName; + return true; + } +} diff --git a/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/README.md b/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/README.md new file mode 100644 index 00000000000..ddce21ddf94 --- /dev/null +++ b/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/README.md @@ -0,0 +1,21 @@ +# Microsoft.DotNet.FileBasedPrograms Source Package + +This is a source package containing shared code for [file-based programs](../../../documentation/general/dotnet-run-file.md) support. This package is intended only for internal use by .NET components. + +## Usage in Consuming Projects + +To use this package in your project, add the following to your `.csproj` file: + +```xml + + + + + + + + $(DefineConstants);FILE_BASED_PROGRAMS_SOURCE_PACKAGE_GRACEFUL_EXCEPTION + +``` diff --git a/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.cs.xlf b/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.cs.xlf new file mode 100644 index 00000000000..37292a467a3 --- /dev/null +++ b/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.cs.xlf @@ -0,0 +1,82 @@ + + + + + + Some directives cannot be converted. Run the file to see all compilation errors. Specify '--force' to convert anyway. + Některé direktivy nelze převést. Spuštěním souboru zobrazíte všechny chyby kompilace. Zadejte „--force“, pokud chcete přesto provést převod. + {Locked="--force"} + + + Could not find any project in `{0}`. + V {0} se nenašel žádný projekt. + + + + Could not find project or directory `{0}`. + Nenašel se projekt ani adresář {0}. + + + + error + chyba + Used when reporting directive errors like "file(location): error: message". + + + Duplicate directives are not supported: {0} + 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. + + + + 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 '='. + + + The '#:project' directive is invalid: {0} + Direktiva #:project je neplatná: {0} + {0} is the inner error message. + + + Missing name of '{0}'. + Chybí název pro: {0}. + {0} is the directive name like 'package' or 'sdk'. + + + Found more than one project in `{0}`. Specify which one to use. + V {0} se našlo několik projektů. Vyberte, který z nich chcete použít. + + + + Invalid property name: {0} + Neplatný název vlastnosti: {0} + {0} is an inner exception message. + + + The property directive needs to have two parts separated by '=' like '#:property PropertyName=PropertyValue'. + Direktiva property musí mít dvě části oddělené znakem =, například #:property PropertyName=PropertyValue. + {Locked="#:property"} + + + Directives currently cannot contain double quotes ("). + Direktivy v současné době nemůžou obsahovat dvojité uvozovky ("). + + + + Static graph restore is not supported for file-based apps. Remove the '#:property'. + Statické obnovení grafu se pro souborové aplikace nepodporuje. Odeberte #:property. + {Locked="#:property"} + + + Unrecognized directive '{0}'. + Nerozpoznaná direktiva {0}. + {0} is the directive name like 'package' or 'sdk'. + + + + \ No newline at end of file diff --git a/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.de.xlf b/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.de.xlf new file mode 100644 index 00000000000..991dcca846e --- /dev/null +++ b/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.de.xlf @@ -0,0 +1,82 @@ + + + + + + Some directives cannot be converted. Run the file to see all compilation errors. Specify '--force' to convert anyway. + Einige Anweisungen können nicht konvertiert werden. Führen Sie die Datei aus, um alle Kompilierungsfehler anzuzeigen. Geben Sie „--force“ an, um das Umwandeln trotzdem auszuführen. + {Locked="--force"} + + + Could not find any project in `{0}`. + In "{0}" wurde kein Projekt gefunden. + + + + Could not find project or directory `{0}`. + Das Projekt oder Verzeichnis "{0}" wurde nicht gefunden. + + + + error + Fehler + Used when reporting directive errors like "file(location): error: message". + + + Duplicate directives are not supported: {0} + 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. + + + + 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 '='. + + + The '#:project' directive is invalid: {0} + Die Anweisung „#:p roject“ ist ungültig: {0} + {0} is the inner error message. + + + Missing name of '{0}'. + Fehlender Name der Anweisung „{0}“. + {0} is the directive name like 'package' or 'sdk'. + + + Found more than one project in `{0}`. Specify which one to use. + In "{0}" wurden mehrere Projekte gefunden. Geben Sie an, welches davon verwendet werden soll. + + + + Invalid property name: {0} + Ungültiger Eigenschaftenname: {0} + {0} is an inner exception message. + + + The property directive needs to have two parts separated by '=' like '#:property PropertyName=PropertyValue'. + Die Eigenschaftsanweisung muss zwei durch „=“ getrennte Teile aufweisen, z. B. „#:property PropertyName=PropertyValue“. + {Locked="#:property"} + + + Directives currently cannot contain double quotes ("). + Direktiven dürfen derzeit keine doppelten Anführungszeichen (") enthalten. + + + + Static graph restore is not supported for file-based apps. Remove the '#:property'. + Die Statische Graphwiederherstellung wird für dateibasierte Apps nicht unterstützt. Entfernen Sie '#:property'. + {Locked="#:property"} + + + Unrecognized directive '{0}'. + Unbekannte Anweisung „{0}“. + {0} is the directive name like 'package' or 'sdk'. + + + + \ No newline at end of file diff --git a/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.es.xlf b/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.es.xlf new file mode 100644 index 00000000000..2983aa595df --- /dev/null +++ b/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.es.xlf @@ -0,0 +1,82 @@ + + + + + + Some directives cannot be converted. Run the file to see all compilation errors. Specify '--force' to convert anyway. + Algunas directivas no se pueden convertir. Ejecute el archivo para ver todos los errores de compilación. Especifique "--force" para convertir de todos modos. + {Locked="--force"} + + + Could not find any project in `{0}`. + No se encuentra ningún proyecto en "{0}". + + + + Could not find project or directory `{0}`. + No se encuentra el proyecto o directorio "{0}". + + + + error + error + Used when reporting directive errors like "file(location): error: message". + + + Duplicate directives are not supported: {0} + 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. + + + + 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 '='. + + + The '#:project' directive is invalid: {0} + La directiva "#:project" no es válida: {0} + {0} is the inner error message. + + + Missing name of '{0}'. + Falta el nombre de "{0}". + {0} is the directive name like 'package' or 'sdk'. + + + Found more than one project in `{0}`. Specify which one to use. + Se han encontrado varios proyectos en "{0}". Especifique el que debe usarse. + + + + Invalid property name: {0} + Nombre de propiedad no válido {0} + {0} is an inner exception message. + + + The property directive needs to have two parts separated by '=' like '#:property PropertyName=PropertyValue'. + La directiva de propiedad debe tener dos partes separadas por "=", como "#:property PropertyName=PropertyValue". + {Locked="#:property"} + + + Directives currently cannot contain double quotes ("). + Las directivas no pueden contener comillas dobles ("), por ahora. + + + + Static graph restore is not supported for file-based apps. Remove the '#:property'. + No se admite la restauración de gráficos estáticos para aplicaciones basadas en archivos. Elimine "#:property". + {Locked="#:property"} + + + Unrecognized directive '{0}'. + Directiva no reconocida "{0}". + {0} is the directive name like 'package' or 'sdk'. + + + + \ No newline at end of file diff --git a/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.fr.xlf b/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.fr.xlf new file mode 100644 index 00000000000..1fabce52be8 --- /dev/null +++ b/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.fr.xlf @@ -0,0 +1,82 @@ + + + + + + Some directives cannot be converted. Run the file to see all compilation errors. Specify '--force' to convert anyway. + Vous ne pouvez pas convertir certaines directives. Exécutez le fichier pour voir toutes les erreurs de compilation. Spécifiez « --force » pour convertir quand même. + {Locked="--force"} + + + Could not find any project in `{0}`. + Projet introuvable dans '{0}'. + + + + Could not find project or directory `{0}`. + Projet ou répertoire '{0}' introuvable. + + + + error + erreur + Used when reporting directive errors like "file(location): error: message". + + + Duplicate directives are not supported: {0} + 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. + + + + 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 '='. + + + The '#:project' directive is invalid: {0} + La directive « #:project » n’est pas valide : {0} + {0} is the inner error message. + + + Missing name of '{0}'. + Nom manquant pour « {0} ». + {0} is the directive name like 'package' or 'sdk'. + + + Found more than one project in `{0}`. Specify which one to use. + Plusieurs projets dans '{0}'. Spécifiez celui à utiliser. + + + + Invalid property name: {0} + Nom de propriété non valide : {0} + {0} is an inner exception message. + + + The property directive needs to have two parts separated by '=' like '#:property PropertyName=PropertyValue'. + La directive de propriété doit avoir deux parties séparées par '=' comme '#:property PropertyName=PropertyValue'. + {Locked="#:property"} + + + Directives currently cannot contain double quotes ("). + Les directives ne peuvent actuellement pas contenir de guillemets doubles ("). + + + + Static graph restore is not supported for file-based apps. Remove the '#:property'. + La restauration de graphique statique n’est pas prise en charge pour les applications basées sur des fichiers. Supprimer la « #:property ». + {Locked="#:property"} + + + Unrecognized directive '{0}'. + Directive « {0} » non reconnue. + {0} is the directive name like 'package' or 'sdk'. + + + + \ No newline at end of file diff --git a/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.it.xlf b/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.it.xlf new file mode 100644 index 00000000000..93fdb8209fd --- /dev/null +++ b/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.it.xlf @@ -0,0 +1,82 @@ + + + + + + Some directives cannot be converted. Run the file to see all compilation errors. Specify '--force' to convert anyway. + Non è possibile convertire alcune direttive. Eseguire il file per visualizzare tutti gli errori di compilazione. Specificare '--force' per eseguire comunque la conversione. + {Locked="--force"} + + + Could not find any project in `{0}`. + Non è stato trovato alcun progetto in `{0}`. + + + + Could not find project or directory `{0}`. + Non sono stati trovati progetti o directory `{0}`. + + + + error + errore + Used when reporting directive errors like "file(location): error: message". + + + Duplicate directives are not supported: {0} + 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. + + + + 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 '='. + + + The '#:project' directive is invalid: {0} + La direttiva '#:project' non è valida: {0} + {0} is the inner error message. + + + Missing name of '{0}'. + Manca il nome di '{0}'. + {0} is the directive name like 'package' or 'sdk'. + + + Found more than one project in `{0}`. Specify which one to use. + Sono stati trovati più progetti in `{0}`. Specificare quello da usare. + + + + Invalid property name: {0} + Nome proprietà non valido: {0} + {0} is an inner exception message. + + + The property directive needs to have two parts separated by '=' like '#:property PropertyName=PropertyValue'. + La direttiva di proprietà deve avere due parti delimitate da '=', come '#:property PropertyName=PropertyValue'. + {Locked="#:property"} + + + Directives currently cannot contain double quotes ("). + Le direttive attualmente non possono contenere virgolette doppie ("). + + + + Static graph restore is not supported for file-based apps. Remove the '#:property'. + Il ripristino statico del grafo non è supportato per le app basate su file. Rimuovere '#:property'. + {Locked="#:property"} + + + Unrecognized directive '{0}'. + Direttiva non riconosciuta '{0}'. + {0} is the directive name like 'package' or 'sdk'. + + + + \ No newline at end of file diff --git a/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ja.xlf b/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ja.xlf new file mode 100644 index 00000000000..f76d2b52824 --- /dev/null +++ b/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ja.xlf @@ -0,0 +1,82 @@ + + + + + + Some directives cannot be converted. Run the file to see all compilation errors. Specify '--force' to convert anyway. + 一部のディレクティブは変換できません。ファイルを実行して、すべてのコンパイル エラーを表示します。それでも変換する場合は '--force' を指定してください。 + {Locked="--force"} + + + Could not find any project in `{0}`. + `{0}` にプロジェクトが見つかりませんでした。 + + + + Could not find project or directory `{0}`. + プロジェクトまたはディレクトリ `{0}` が見つかりませんでした。 + + + + error + エラー + Used when reporting directive errors like "file(location): error: message". + + + Duplicate directives are not supported: {0} + 重複するディレクティブはサポートされていません: {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 でローカル アプリ データ フォルダーを構成することを検討してください。 + + + + 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 '='. + + + The '#:project' directive is invalid: {0} + '#:p roject' ディレクティブが無効です: {0} + {0} is the inner error message. + + + Missing name of '{0}'. + '{0}' の名前がありません。 + {0} is the directive name like 'package' or 'sdk'. + + + Found more than one project in `{0}`. Specify which one to use. + `{0}` に複数のプロジェクトが見つかりました。使用するプロジェクトを指定してください。 + + + + Invalid property name: {0} + 無効なプロパティ名: {0} + {0} is an inner exception message. + + + The property directive needs to have two parts separated by '=' like '#:property PropertyName=PropertyValue'. + プロパティ ディレクティブには、'#:property PropertyName=PropertyValue' のように '=' で区切られた 2 つの部分が必要です。 + {Locked="#:property"} + + + Directives currently cannot contain double quotes ("). + ディレクティブには二重引用符 (") を含めることはできません。 + + + + Static graph restore is not supported for file-based apps. Remove the '#:property'. + 静的グラフの復元はファイルベースのアプリではサポートされていません。'#:property' を削除します。 + {Locked="#:property"} + + + Unrecognized directive '{0}'. + 認識されないディレクティブ '{0}' です。 + {0} is the directive name like 'package' or 'sdk'. + + + + \ No newline at end of file diff --git a/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ko.xlf b/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ko.xlf new file mode 100644 index 00000000000..afdd17c8f43 --- /dev/null +++ b/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ko.xlf @@ -0,0 +1,82 @@ + + + + + + Some directives cannot be converted. Run the file to see all compilation errors. Specify '--force' to convert anyway. + 일부 지시문을 변환할 수 없습니다. 파일을 실행하여 모든 컴파일 오류를 확인하세요. 변환을 강제로 진행하려면 '--force'를 지정하세요. + {Locked="--force"} + + + Could not find any project in `{0}`. + '{0}'에서 프로젝트를 찾을 수 없습니다. + + + + Could not find project or directory `{0}`. + 프로젝트 또는 디렉터리 {0}을(를) 찾을 수 없습니다. + + + + error + 오류 + Used when reporting directive errors like "file(location): error: message". + + + Duplicate directives are not supported: {0} + 중복 지시문은 지원되지 않습니다. {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에서는 로컬 앱 데이터 폴더를 설정하는 것이 좋습니다. + + + + 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 '='. + + + The '#:project' directive is invalid: {0} + '#:p roject' 지시문이 잘못되었습니다. {0} + {0} is the inner error message. + + + Missing name of '{0}'. + '{0}' 이름이 없습니다. + {0} is the directive name like 'package' or 'sdk'. + + + Found more than one project in `{0}`. Specify which one to use. + '{0}'에서 프로젝트를 두 개 이상 찾았습니다. 사용할 프로젝트를 지정하세요. + + + + Invalid property name: {0} + 잘못된 속성 이름: {0} + {0} is an inner exception message. + + + The property directive needs to have two parts separated by '=' like '#:property PropertyName=PropertyValue'. + property 지시문에는 '#:property PropertyName=PropertyValue'와 같이 '='로 구분된 두 부분이 있어야 합니다. + {Locked="#:property"} + + + Directives currently cannot contain double quotes ("). + 지시문은 현재 큰따옴표(")를 포함할 수 없습니다. + + + + Static graph restore is not supported for file-based apps. Remove the '#:property'. + 정적 그래프 복원은 파일 기반 앱에서 지원되지 않습니다. '#:property'를 제거합니다. + {Locked="#:property"} + + + Unrecognized directive '{0}'. + 인식할 수 없는 지시문 '{0}'입니다. + {0} is the directive name like 'package' or 'sdk'. + + + + \ No newline at end of file diff --git a/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.pl.xlf b/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.pl.xlf new file mode 100644 index 00000000000..5e91ebbb30c --- /dev/null +++ b/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.pl.xlf @@ -0,0 +1,82 @@ + + + + + + Some directives cannot be converted. Run the file to see all compilation errors. Specify '--force' to convert anyway. + Nie można przekonwertować niektórych dyrektyw. Uruchom plik, aby wyświetlić wszystkie błędy kompilacji. Określ element „--force”, aby mimo to przekonwertować. + {Locked="--force"} + + + Could not find any project in `{0}`. + Nie można odnaleźć żadnego projektu w lokalizacji „{0}”. + + + + Could not find project or directory `{0}`. + Nie można odnaleźć projektu ani katalogu „{0}”. + + + + error + błąd + Used when reporting directive errors like "file(location): error: message". + + + Duplicate directives are not supported: {0} + 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. + + + + 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 '='. + + + The '#:project' directive is invalid: {0} + Dyrektywa „#:project” jest nieprawidłowa: {0} + {0} is the inner error message. + + + Missing name of '{0}'. + Brak nazwy „{0}”. + {0} is the directive name like 'package' or 'sdk'. + + + Found more than one project in `{0}`. Specify which one to use. + Znaleziono więcej niż jeden projekt w lokalizacji „{0}”. Określ, który ma zostać użyty. + + + + Invalid property name: {0} + Nieprawidłowa nazwa właściwości: {0} + {0} is an inner exception message. + + + The property directive needs to have two parts separated by '=' like '#:property PropertyName=PropertyValue'. + Dyrektywa właściwości musi mieć dwie części oddzielone znakiem „=”, na przykład „#:property PropertyName=PropertyValue”. + {Locked="#:property"} + + + Directives currently cannot contain double quotes ("). + Dyrektywy nie mogą obecnie zawierać podwójnych cudzysłowów ("). + + + + Static graph restore is not supported for file-based apps. Remove the '#:property'. + Przywracanie statycznego grafu nie jest obsługiwane w przypadku aplikacji opartych na plikach. Usuń element „#:property”. + {Locked="#:property"} + + + Unrecognized directive '{0}'. + Nierozpoznana dyrektywa „{0}”. + {0} is the directive name like 'package' or 'sdk'. + + + + \ No newline at end of file diff --git a/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.pt-BR.xlf b/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.pt-BR.xlf new file mode 100644 index 00000000000..f44fda05d92 --- /dev/null +++ b/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.pt-BR.xlf @@ -0,0 +1,82 @@ + + + + + + Some directives cannot be converted. Run the file to see all compilation errors. Specify '--force' to convert anyway. + Algumas diretivas não podem ser convertidas. Execute o arquivo para ver todos os erros de compilação. Especifique '--force' para converter mesmo assim. + {Locked="--force"} + + + Could not find any project in `{0}`. + Não foi possível encontrar nenhum projeto em ‘{0}’. + + + + Could not find project or directory `{0}`. + Não foi possível encontrar o projeto ou diretório ‘{0}’. + + + + error + erro + Used when reporting directive errors like "file(location): error: message". + + + Duplicate directives are not supported: {0} + 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. + + + + 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 '='. + + + The '#:project' directive is invalid: {0} + A diretiva '#:project' é inválida:{0} + {0} is the inner error message. + + + Missing name of '{0}'. + Nome de '{0}' ausente. + {0} is the directive name like 'package' or 'sdk'. + + + Found more than one project in `{0}`. Specify which one to use. + Foi encontrado mais de um projeto em ‘{0}’. Especifique qual deve ser usado. + + + + Invalid property name: {0} + Nome de propriedade inválido: {0} + {0} is an inner exception message. + + + The property directive needs to have two parts separated by '=' like '#:property PropertyName=PropertyValue'. + A diretiva de propriedade precisa ter duas partes separadas por '=' como '#:property PropertyName=PropertyValue'. + {Locked="#:property"} + + + Directives currently cannot contain double quotes ("). + No momento, as diretivas não podem conter aspas duplas ("). + + + + Static graph restore is not supported for file-based apps. Remove the '#:property'. + A restauração de grafo estático não é suportada para aplicativos baseados em arquivos. Remova '#:property'. + {Locked="#:property"} + + + Unrecognized directive '{0}'. + Diretiva não reconhecida '{0}'. + {0} is the directive name like 'package' or 'sdk'. + + + + \ No newline at end of file diff --git a/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ru.xlf b/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ru.xlf new file mode 100644 index 00000000000..5e9af0ed594 --- /dev/null +++ b/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ru.xlf @@ -0,0 +1,82 @@ + + + + + + Some directives cannot be converted. Run the file to see all compilation errors. Specify '--force' to convert anyway. + Некоторые директивы невозможно преобразовать. Запустите файл, чтобы увидеть все ошибки компиляции. Укажите параметр "--force", чтобы выполнить преобразование, невзирая на ошибки. + {Locked="--force"} + + + Could not find any project in `{0}`. + Не удалось найти проекты в "{0}". + + + + Could not find project or directory `{0}`. + Не удалось найти проект или каталог "{0}". + + + + error + ошибка + Used when reporting directive errors like "file(location): error: message". + + + Duplicate directives are not supported: {0} + Повторяющиеся директивы не поддерживаются: {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. + + + + 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 '='. + + + The '#:project' directive is invalid: {0} + Недопустимая директива "#:project": {0} + {0} is the inner error message. + + + Missing name of '{0}'. + Отсутствует имя "{0}". + {0} is the directive name like 'package' or 'sdk'. + + + Found more than one project in `{0}`. Specify which one to use. + Найдено несколько проектов в "{0}". Выберите один. + + + + Invalid property name: {0} + Недопустимое имя свойства: {0} + {0} is an inner exception message. + + + The property directive needs to have two parts separated by '=' like '#:property PropertyName=PropertyValue'. + Директива свойства должна иметь две части, разделенные символом "=", например "#:property PropertyName=PropertyValue". + {Locked="#:property"} + + + Directives currently cannot contain double quotes ("). + В директивах пока нельзя использовать двойные кавычки ("). + + + + Static graph restore is not supported for file-based apps. Remove the '#:property'. + Восстановление статического графа не поддерживается для приложений на основе файлов. Удалите "#:property". + {Locked="#:property"} + + + Unrecognized directive '{0}'. + Нераспознанная директива "{0}". + {0} is the directive name like 'package' or 'sdk'. + + + + \ No newline at end of file diff --git a/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.tr.xlf b/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.tr.xlf new file mode 100644 index 00000000000..93c4d39ae84 --- /dev/null +++ b/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.tr.xlf @@ -0,0 +1,82 @@ + + + + + + Some directives cannot be converted. Run the file to see all compilation errors. Specify '--force' to convert anyway. + Bazı yönergeler dönüştürülemez. Tüm derleme hatalarını görmek için dosyayı çalıştırın. Yine de dönüştürmek için '--force' belirtin. + {Locked="--force"} + + + Could not find any project in `{0}`. + `{0}` içinde proje bulunamadı. + + + + Could not find project or directory `{0}`. + `{0}` projesi veya dizini bulunamadı. + + + + error + hata + Used when reporting directive errors like "file(location): error: message". + + + Duplicate directives are not supported: {0} + 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. + + + + 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 '='. + + + The '#:project' directive is invalid: {0} + ‘#:project’ yönergesi geçersizdir: {0} + {0} is the inner error message. + + + Missing name of '{0}'. + '{0}' adı eksik. + {0} is the directive name like 'package' or 'sdk'. + + + Found more than one project in `{0}`. Specify which one to use. + `{0}` içinde birden fazla proje bulundu. Hangisinin kullanılacağını belirtin. + + + + Invalid property name: {0} + Geçersiz özellik adı: {0} + {0} is an inner exception message. + + + The property directive needs to have two parts separated by '=' like '#:property PropertyName=PropertyValue'. + Özellik yönergesi, ‘#:property PropertyName=PropertyValue’ gibi ‘=’ ile ayrılmış iki bölümden oluşmalıdır. + {Locked="#:property"} + + + Directives currently cannot contain double quotes ("). + Yönergeler şu anda çift tırnak (") içeremez. + + + + Static graph restore is not supported for file-based apps. Remove the '#:property'. + Dosya tabanlı uygulamalar için statik grafik geri yükleme desteklenmemektedir. ‘#:property’i kaldırın. + {Locked="#:property"} + + + Unrecognized directive '{0}'. + Tanınmayan yönerge '{0}'. + {0} is the directive name like 'package' or 'sdk'. + + + + \ No newline at end of file diff --git a/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.zh-Hans.xlf b/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.zh-Hans.xlf new file mode 100644 index 00000000000..27b469f6394 --- /dev/null +++ b/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.zh-Hans.xlf @@ -0,0 +1,82 @@ + + + + + + Some directives cannot be converted. Run the file to see all compilation errors. Specify '--force' to convert anyway. + 一些指令无法转换。运行该文件以查看所有编译错误。请指定 '--force' 以进行转换。 + {Locked="--force"} + + + Could not find any project in `{0}`. + “{0}”中找不到任何项目。 + + + + Could not find project or directory `{0}`. + 找不到项目或目录“{0}”。 + + + + error + 错误 + Used when reporting directive errors like "file(location): error: message". + + + Duplicate directives are not supported: {0} + 不支持重复指令: {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 上配置本地应用数据文件夹。 + + + + 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 '='. + + + The '#:project' directive is invalid: {0} + '#:project' 指令无效: {0} + {0} is the inner error message. + + + Missing name of '{0}'. + 缺少 '{0}' 的名称。 + {0} is the directive name like 'package' or 'sdk'. + + + Found more than one project in `{0}`. Specify which one to use. + 在“{0}”中找到多个项目。请指定使用哪一个。 + + + + Invalid property name: {0} + 属性名无效: {0} + {0} is an inner exception message. + + + The property directive needs to have two parts separated by '=' like '#:property PropertyName=PropertyValue'. + 属性指令需要包含两个由 ‘=’ 分隔的部件,例如 '#:property PropertyName=PropertyValue'。 + {Locked="#:property"} + + + Directives currently cannot contain double quotes ("). + 指令当前不能包含双引号(")。 + + + + Static graph restore is not supported for file-based apps. Remove the '#:property'. + 基于文件的应用不支持静态图形还原。移除 '#:property'。 + {Locked="#:property"} + + + Unrecognized directive '{0}'. + 无法识别的指令 ‘{0}’。 + {0} is the directive name like 'package' or 'sdk'. + + + + \ No newline at end of file diff --git a/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.zh-Hant.xlf b/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.zh-Hant.xlf new file mode 100644 index 00000000000..f5a7d9f89ee --- /dev/null +++ b/src/WatchPrototype/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.zh-Hant.xlf @@ -0,0 +1,82 @@ + + + + + + Some directives cannot be converted. Run the file to see all compilation errors. Specify '--force' to convert anyway. + 無法轉換某些指示詞。執行檔案以查看所有編譯錯誤。指定 '--force' 以繼續轉換。 + {Locked="--force"} + + + Could not find any project in `{0}`. + 在 `{0}` 中找不到任何專案。 + + + + Could not find project or directory `{0}`. + 找不到專案或目錄 `{0}`。 + + + + error + 錯誤 + Used when reporting directive errors like "file(location): error: message". + + + Duplicate directives are not supported: {0} + 不支援重複的指示詞: {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 環境變數。 + + + + 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 '='. + + + The '#:project' directive is invalid: {0} + '#:project' 指示詞無效: {0} + {0} is the inner error message. + + + Missing name of '{0}'. + 缺少 '{0}' 的名稱。 + {0} is the directive name like 'package' or 'sdk'. + + + Found more than one project in `{0}`. Specify which one to use. + 在 `{0}` 中找到多個專案。請指定要使用的專案。 + + + + Invalid property name: {0} + 屬性名稱無效: {0} + {0} is an inner exception message. + + + The property directive needs to have two parts separated by '=' like '#:property PropertyName=PropertyValue'. + 屬性指示詞必須有兩個部分,其以 '=' 分隔,例如 '#:property PropertyName=PropertyValue'。 + {Locked="#:property"} + + + Directives currently cannot contain double quotes ("). + 指令目前不能包含雙引號 (")。 + + + + Static graph restore is not supported for file-based apps. Remove the '#:property'. + 檔案型應用程式不支援靜態圖表還原。移除 ''#:property'。 + {Locked="#:property"} + + + Unrecognized directive '{0}'. + 無法識別的指示詞 '{0}'。 + {0} is the directive name like 'package' or 'sdk'. + + + + \ No newline at end of file diff --git a/src/WatchPrototype/Microsoft.DotNet.ProjectTools/LaunchSettings/ExecutableLaunchProfile.cs b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/LaunchSettings/ExecutableLaunchProfile.cs new file mode 100644 index 00000000000..67db2e0b93c --- /dev/null +++ b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/LaunchSettings/ExecutableLaunchProfile.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 System.Text.Json.Serialization; + +namespace Microsoft.DotNet.ProjectTools; + +public sealed class ExecutableLaunchProfile : LaunchProfile +{ + public const string WorkingDirectoryPropertyName = "workingDirectory"; + public const string ExecutablePathPropertyName = "executablePath"; + + [JsonPropertyName("executablePath")] + public required string ExecutablePath { get; init; } + + [JsonPropertyName("workingDirectory")] + public string? WorkingDirectory { get; init; } +} diff --git a/src/WatchPrototype/Microsoft.DotNet.ProjectTools/LaunchSettings/ExecutableLaunchProfileParser.cs b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/LaunchSettings/ExecutableLaunchProfileParser.cs new file mode 100644 index 00000000000..d6c161793fb --- /dev/null +++ b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/LaunchSettings/ExecutableLaunchProfileParser.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.Diagnostics.CodeAnalysis; +using System.Text.Json; + +namespace Microsoft.DotNet.ProjectTools; + +internal sealed class ExecutableLaunchProfileParser : LaunchProfileParser +{ + public const string CommandName = "Executable"; + + public static readonly ExecutableLaunchProfileParser Instance = new(); + + private ExecutableLaunchProfileParser() + { + } + + public override LaunchProfileParseResult ParseProfile(string launchSettingsPath, string? launchProfileName, string json) + { + var profile = JsonSerializer.Deserialize(json); + if (profile == null) + { + return LaunchProfileParseResult.Failure(Resources.LaunchProfileIsNotAJsonObject); + } + + if (!TryParseWorkingDirectory(launchSettingsPath, profile.WorkingDirectory, out var workingDirectory, out var error)) + { + return LaunchProfileParseResult.Failure(error); + } + + return LaunchProfileParseResult.Success(new ExecutableLaunchProfile + { + LaunchProfileName = launchProfileName, + ExecutablePath = ExpandVariables(profile.ExecutablePath), + CommandLineArgs = ParseCommandLineArgs(profile.CommandLineArgs), + WorkingDirectory = workingDirectory, + DotNetRunMessages = profile.DotNetRunMessages, + EnvironmentVariables = ParseEnvironmentVariables(profile.EnvironmentVariables), + }); + } + + private static bool TryParseWorkingDirectory(string launchSettingsPath, string? value, out string? workingDirectory, [NotNullWhen(false)] out string? error) + { + if (value == null) + { + workingDirectory = null; + error = null; + return true; + } + + var expandedValue = ExpandVariables(value); + + try + { + workingDirectory = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(launchSettingsPath)!, expandedValue)); + error = null; + return true; + } + catch + { + workingDirectory = null; + error = string.Format(Resources.Path0SpecifiedIn1IsInvalid, expandedValue, ExecutableLaunchProfile.WorkingDirectoryPropertyName); + return false; + } + } +} diff --git a/src/WatchPrototype/Microsoft.DotNet.ProjectTools/LaunchSettings/LaunchProfile.cs b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/LaunchSettings/LaunchProfile.cs new file mode 100644 index 00000000000..87f6275e83b --- /dev/null +++ b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/LaunchSettings/LaunchProfile.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.Collections.Immutable; +using System.Text.Json.Serialization; + +namespace Microsoft.DotNet.ProjectTools; + +public abstract class LaunchProfile +{ + [JsonIgnore] + public string? LaunchProfileName { get; init; } + + [JsonPropertyName("dotnetRunMessages")] + public bool DotNetRunMessages { get; init; } + + [JsonPropertyName("commandLineArgs")] + public string? CommandLineArgs { get; init; } + + [JsonPropertyName("environmentVariables")] + public ImmutableDictionary EnvironmentVariables { get; init; } = []; +} diff --git a/src/WatchPrototype/Microsoft.DotNet.ProjectTools/LaunchSettings/LaunchProfileParseResult.cs b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/LaunchSettings/LaunchProfileParseResult.cs new file mode 100644 index 00000000000..bf735a81cf0 --- /dev/null +++ b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/LaunchSettings/LaunchProfileParseResult.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. + +using System.Diagnostics.CodeAnalysis; + +namespace Microsoft.DotNet.ProjectTools; + +public sealed class LaunchProfileParseResult +{ + public string? FailureReason { get; } + + public LaunchProfile? Profile { get; } + + private LaunchProfileParseResult(string? failureReason, LaunchProfile? profile) + { + FailureReason = failureReason; + Profile = profile; + } + + [MemberNotNullWhen(false, nameof(FailureReason))] + public bool Successful + => FailureReason == null; + + public static LaunchProfileParseResult Failure(string reason) + => new(reason, profile: null); + + public static LaunchProfileParseResult Success(LaunchProfile? model) + => new(failureReason: null, profile: model); +} diff --git a/src/WatchPrototype/Microsoft.DotNet.ProjectTools/LaunchSettings/LaunchProfileParser.cs b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/LaunchSettings/LaunchProfileParser.cs new file mode 100644 index 00000000000..0d7feb52be0 --- /dev/null +++ b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/LaunchSettings/LaunchProfileParser.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. + +using System.Collections.Immutable; + +namespace Microsoft.DotNet.ProjectTools; + +internal abstract class LaunchProfileParser +{ + public abstract LaunchProfileParseResult ParseProfile(string launchSettingsPath, string? launchProfileName, string json); + + protected static string? ParseCommandLineArgs(string? value) + => value != null ? ExpandVariables(value) : null; + + public static string GetLaunchProfileDisplayName(string? launchProfile) + => string.IsNullOrEmpty(launchProfile) ? Resources.DefaultLaunchProfileDisplayName : launchProfile; + + protected static ImmutableDictionary ParseEnvironmentVariables(ImmutableDictionary values) + { + if (values.Count == 0) + { + return values; + } + + var builder = ImmutableDictionary.CreateBuilder(StringComparer.Ordinal); + foreach (var (key, value) in values) + { + // override previously set variables: + builder[key] = ExpandVariables(value); + } + + return builder.ToImmutable(); + } + + // TODO: Expand MSBuild variables $(...): https://github.com/dotnet/sdk/issues/50157 + // See https://github.com/dotnet/project-system/blob/main/src/Microsoft.VisualStudio.ProjectSystem.Managed/ProjectSystem/Debug/DebugTokenReplacer.cs#L35-L57 + protected static string ExpandVariables(string value) + => Environment.ExpandEnvironmentVariables(value); +} diff --git a/src/WatchPrototype/Microsoft.DotNet.ProjectTools/LaunchSettings/LaunchSettings.cs b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/LaunchSettings/LaunchSettings.cs new file mode 100644 index 00000000000..ae1c60161b2 --- /dev/null +++ b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/LaunchSettings/LaunchSettings.cs @@ -0,0 +1,195 @@ +// 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.Diagnostics.CodeAnalysis; +using System.Text.Json; + +namespace Microsoft.DotNet.ProjectTools; + +public static class LaunchSettings +{ + private const string ProfilesKey = "profiles"; + private const string CommandNameKey = "commandName"; + + private static readonly IReadOnlyDictionary s_providers = new Dictionary + { + { ProjectLaunchProfileParser.CommandName, ProjectLaunchProfileParser.Instance }, + { ExecutableLaunchProfileParser.CommandName, ExecutableLaunchProfileParser.Instance } + }; + + public static IEnumerable SupportedProfileTypes => s_providers.Keys; + + public static string GetPropertiesLaunchSettingsPath(string directoryPath, string propertiesDirectoryName) + => Path.Combine(directoryPath, propertiesDirectoryName, "launchSettings.json"); + + public static string GetFlatLaunchSettingsPath(string directoryPath, string projectNameWithoutExtension) + => Path.Join(directoryPath, $"{projectNameWithoutExtension}.run.json"); + + public static string? TryFindLaunchSettingsFile(string projectOrEntryPointFilePath, string? launchProfile, Action report) + { + var buildPathContainer = Path.GetDirectoryName(projectOrEntryPointFilePath); + Debug.Assert(buildPathContainer != null); + + // VB.NET projects store the launch settings file in the + // "My Project" directory instead of a "Properties" directory. + // TODO: use the `AppDesignerFolder` MSBuild property instead, which captures this logic already + var propsDirectory = string.Equals(Path.GetExtension(projectOrEntryPointFilePath), ".vbproj", StringComparison.OrdinalIgnoreCase) + ? "My Project" + : "Properties"; + + string launchSettingsPath = GetPropertiesLaunchSettingsPath(buildPathContainer, propsDirectory); + bool hasLaunchSetttings = File.Exists(launchSettingsPath); + + string appName = Path.GetFileNameWithoutExtension(projectOrEntryPointFilePath); + string runJsonPath = GetFlatLaunchSettingsPath(buildPathContainer, appName); + bool hasRunJson = File.Exists(runJsonPath); + + if (hasLaunchSetttings) + { + if (hasRunJson) + { + report(string.Format(Resources.RunCommandWarningRunJsonNotUsed, runJsonPath, launchSettingsPath), false); + } + + return launchSettingsPath; + } + + if (hasRunJson) + { + return runJsonPath; + } + + if (!string.IsNullOrEmpty(launchProfile)) + { + report(string.Format(Resources.RunCommandExceptionCouldNotLocateALaunchSettingsFile, launchProfile, $""" + {launchSettingsPath} + {runJsonPath} + """), true); + } + + return null; + } + + public static LaunchProfileParseResult ReadProfileSettingsFromFile(string launchSettingsPath, string? profileName = null) + { + try + { + var launchSettingsJsonContents = File.ReadAllText(launchSettingsPath); + + var jsonDocumentOptions = new JsonDocumentOptions + { + CommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true, + }; + + using (var document = JsonDocument.Parse(launchSettingsJsonContents, jsonDocumentOptions)) + { + var model = document.RootElement; + + if (model.ValueKind != JsonValueKind.Object || !model.TryGetProperty(ProfilesKey, out var profilesObject) || profilesObject.ValueKind != JsonValueKind.Object) + { + return LaunchProfileParseResult.Failure(Resources.LaunchProfilesCollectionIsNotAJsonObject); + } + + var selectedProfileName = profileName; + JsonElement profileObject; + if (string.IsNullOrEmpty(profileName)) + { + var firstProfileProperty = profilesObject.EnumerateObject().FirstOrDefault(IsDefaultProfileType); + selectedProfileName = firstProfileProperty.Value.ValueKind == JsonValueKind.Object ? firstProfileProperty.Name : null; + profileObject = firstProfileProperty.Value; + } + else // Find a profile match for the given profileName + { + IEnumerable caseInsensitiveProfileMatches = [.. profilesObject + .EnumerateObject() // p.Name shouldn't fail, as profileObject enumerables here are only created from an existing JsonObject + .Where(p => string.Equals(p.Name, profileName, StringComparison.OrdinalIgnoreCase))]; + + if (caseInsensitiveProfileMatches.Count() > 1) + { + return LaunchProfileParseResult.Failure(string.Format(Resources.DuplicateCaseInsensitiveLaunchProfileNames, + string.Join(",\n", caseInsensitiveProfileMatches.Select(p => $"\t{p.Name}")))); + } + + if (!caseInsensitiveProfileMatches.Any()) + { + return LaunchProfileParseResult.Failure(string.Format(Resources.LaunchProfileDoesNotExist, profileName)); + } + + profileObject = profilesObject.GetProperty(caseInsensitiveProfileMatches.First().Name); + + if (profileObject.ValueKind != JsonValueKind.Object) + { + return LaunchProfileParseResult.Failure(Resources.LaunchProfileIsNotAJsonObject); + } + } + + if (profileObject.ValueKind == default) + { + foreach (var prop in profilesObject.EnumerateObject()) + { + if (prop.Value.ValueKind == JsonValueKind.Object) + { + if (prop.Value.TryGetProperty(CommandNameKey, out var commandNameElement) && commandNameElement.ValueKind == JsonValueKind.String) + { + if (commandNameElement.GetString() is { } commandNameElementKey && s_providers.ContainsKey(commandNameElementKey)) + { + profileObject = prop.Value; + break; + } + } + } + } + } + + if (profileObject.ValueKind == default) + { + return LaunchProfileParseResult.Failure(Resources.UsableLaunchProfileCannotBeLocated); + } + + if (!profileObject.TryGetProperty(CommandNameKey, out var finalCommandNameElement) + || finalCommandNameElement.ValueKind != JsonValueKind.String) + { + return LaunchProfileParseResult.Failure(Resources.UsableLaunchProfileCannotBeLocated); + } + + string? commandName = finalCommandNameElement.GetString(); + if (!TryLocateHandler(commandName, out LaunchProfileParser? provider)) + { + return LaunchProfileParseResult.Failure(string.Format(Resources.LaunchProfileHandlerCannotBeLocated, commandName)); + } + + return provider.ParseProfile(launchSettingsPath, selectedProfileName, profileObject.GetRawText()); + } + } + catch (Exception ex) when (ex is JsonException or IOException) + { + return LaunchProfileParseResult.Failure(string.Format(Resources.DeserializationExceptionMessage, launchSettingsPath, ex.Message)); + } + } + + private static bool TryLocateHandler(string? commandName, [NotNullWhen(true)] out LaunchProfileParser? provider) + { + if (commandName == null) + { + provider = null; + return false; + } + + return s_providers.TryGetValue(commandName, out provider); + } + + private static bool IsDefaultProfileType(JsonProperty profileProperty) + { + if (profileProperty.Value.ValueKind != JsonValueKind.Object + || !profileProperty.Value.TryGetProperty(CommandNameKey, out var commandNameElement) + || commandNameElement.ValueKind != JsonValueKind.String) + { + return false; + } + + var commandName = commandNameElement.GetString(); + return commandName != null && s_providers.ContainsKey(commandName); + } +} diff --git a/src/WatchPrototype/Microsoft.DotNet.ProjectTools/LaunchSettings/ProjectLaunchProfile.cs b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/LaunchSettings/ProjectLaunchProfile.cs new file mode 100644 index 00000000000..a107dc29db8 --- /dev/null +++ b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/LaunchSettings/ProjectLaunchProfile.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 System.Text.Json.Serialization; + +namespace Microsoft.DotNet.ProjectTools; + +public sealed class ProjectLaunchProfile : LaunchProfile +{ + [JsonPropertyName("launchBrowser")] + public bool LaunchBrowser { get; init; } + + [JsonPropertyName("launchUrl")] + public string? LaunchUrl { get; init; } + + [JsonPropertyName("applicationUrl")] + public string? ApplicationUrl { get; init; } +} diff --git a/src/WatchPrototype/Microsoft.DotNet.ProjectTools/LaunchSettings/ProjectLaunchProfileParser.cs b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/LaunchSettings/ProjectLaunchProfileParser.cs new file mode 100644 index 00000000000..c1997d66f17 --- /dev/null +++ b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/LaunchSettings/ProjectLaunchProfileParser.cs @@ -0,0 +1,37 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; + +namespace Microsoft.DotNet.ProjectTools; + +internal sealed class ProjectLaunchProfileParser : LaunchProfileParser +{ + public const string CommandName = "Project"; + + public static readonly ProjectLaunchProfileParser Instance = new(); + + private ProjectLaunchProfileParser() + { + } + + public override LaunchProfileParseResult ParseProfile(string launchSettingsPath, string? launchProfileName, string json) + { + var profile = JsonSerializer.Deserialize(json); + if (profile == null) + { + return LaunchProfileParseResult.Failure(Resources.LaunchProfileIsNotAJsonObject); + } + + return LaunchProfileParseResult.Success(new ProjectLaunchProfile + { + LaunchProfileName = launchProfileName, + CommandLineArgs = ParseCommandLineArgs(profile.CommandLineArgs), + LaunchBrowser = profile.LaunchBrowser, + LaunchUrl = profile.LaunchUrl, + ApplicationUrl = profile.ApplicationUrl, + DotNetRunMessages = profile.DotNetRunMessages, + EnvironmentVariables = ParseEnvironmentVariables(profile.EnvironmentVariables), + }); + } +} diff --git a/src/WatchPrototype/Microsoft.DotNet.ProjectTools/Microsoft.DotNet.ProjectTools.csproj b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/Microsoft.DotNet.ProjectTools.csproj new file mode 100644 index 00000000000..83af1863d36 --- /dev/null +++ b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/Microsoft.DotNet.ProjectTools.csproj @@ -0,0 +1,28 @@ + + + $(SdkTargetFramework) + enable + true + enable + MicrosoftAspNetCore + + + + + + + + + + + + + + + + + + + + + diff --git a/src/WatchPrototype/Microsoft.DotNet.ProjectTools/Resources.resx b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/Resources.resx new file mode 100644 index 00000000000..0c69ccc49b2 --- /dev/null +++ b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/Resources.resx @@ -0,0 +1,158 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Warning: Settings from '{0}' are not used because '{1}' has precedence. + {0} is an app.run.json file path. {1} is a launchSettings.json file path. + + + Cannot use launch profile '{0}' because the launch settings file could not be located. Locations tried: +{1} + + + A profile with the specified name isn't a valid JSON object. + + + The 'profiles' property of the launch settings document is not a JSON object. + + + There are several launch profiles with case-sensitive names, which isn't permitted: +{0} +Make the profile names distinct. + + + A launch profile with the name '{0}' doesn't exist. + + + A usable launch profile could not be located. + + + The launch profile type '{0}' is not supported. + + + An error was encountered when reading '{0}': {1} + {0} is file path. {1} is exception message. + + + Path '{0}' specified in '{1}' is invalid. + + + (Default) + + diff --git a/src/WatchPrototype/Microsoft.DotNet.ProjectTools/Utilities/Sha256Hasher.cs b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/Utilities/Sha256Hasher.cs new file mode 100644 index 00000000000..176173e776c --- /dev/null +++ b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/Utilities/Sha256Hasher.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 System.Security.Cryptography; + +namespace Microsoft.DotNet.Utilities; + +public 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) + /// + public static string Hash(string text) + => Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(text))); + + public static string HashWithNormalizedCasing(string text) + => Hash(text.ToUpperInvariant()); +} diff --git a/src/WatchPrototype/Microsoft.DotNet.ProjectTools/VirtualProjectBuilder.cs b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/VirtualProjectBuilder.cs new file mode 100644 index 00000000000..5a99e0e6373 --- /dev/null +++ b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/VirtualProjectBuilder.cs @@ -0,0 +1,562 @@ +// 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.Security; +using System.Xml; +using Microsoft.Build.Construction; +using Microsoft.Build.Definition; +using Microsoft.Build.Evaluation; +using Microsoft.Build.Execution; +using Microsoft.CodeAnalysis.Text; +using Microsoft.DotNet.FileBasedPrograms; +using Microsoft.DotNet.Utilities; + +namespace Microsoft.DotNet.ProjectTools; + +internal sealed class VirtualProjectBuilder +{ + private readonly IEnumerable<(string name, string value)> _defaultProperties; + + public string EntryPointFileFullPath { get; } + + public SourceFile EntryPointSourceFile + { + get + { + if (field == default) + { + field = SourceFile.Load(EntryPointFileFullPath); + } + + return field; + } + } + + public string ArtifactsPath + => field ??= GetArtifactsPath(EntryPointFileFullPath); + + public string[]? RequestedTargets { get; } + + public VirtualProjectBuilder( + string entryPointFileFullPath, + string targetFramework, + string[]? requestedTargets = null, + string? artifactsPath = null) + { + Debug.Assert(Path.IsPathFullyQualified(entryPointFileFullPath)); + + EntryPointFileFullPath = entryPointFileFullPath; + RequestedTargets = requestedTargets; + ArtifactsPath = artifactsPath; + _defaultProperties = GetDefaultProperties(targetFramework); + } + + /// + /// 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) => + [ + ("OutputType", "Exe"), + ("TargetFramework", targetFramework), + ("ImplicitUsings", "enable"), + ("Nullable", "enable"), + ("PublishAot", "true"), + ("PackAsTool", "true"), + ]; + + public static string GetArtifactsPath(string entryPointFileFullPath) + { + // Include entry point file name so the directory name is not completely opaque. + string fileName = Path.GetFileNameWithoutExtension(entryPointFileFullPath); + string hash = Sha256Hasher.HashWithNormalizedCasing(entryPointFileFullPath); + string directoryName = $"{fileName}-{hash}"; + + return GetTempSubpath(directoryName); + } + + public static string GetVirtualProjectPath(string entryPointFilePath) + => Path.ChangeExtension(entryPointFilePath, ".csproj"); + + /// + /// Obtains a temporary subdirectory for file-based app artifacts, e.g., /tmp/dotnet/runfile/. + /// + public static string GetTempSubdirectory() + { + // We want a location where permissions are expected to be restricted to the current user. + string directory = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? Path.GetTempPath() + : Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + + if (string.IsNullOrEmpty(directory)) + { + throw new InvalidOperationException(FileBasedProgramsResources.EmptyTempPath); + } + + return Path.Join(directory, "dotnet", "runfile"); + } + + /// + /// 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) + { + return Path.Join(GetTempSubdirectory(), name); + } + + public static bool IsValidEntryPointPath(string entryPointFilePath) + { + if (!File.Exists(entryPointFilePath)) + { + return false; + } + + if (entryPointFilePath.EndsWith(".cs", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + // Check if the first two characters are #! + try + { + using var stream = File.OpenRead(entryPointFilePath); + int first = stream.ReadByte(); + int second = stream.ReadByte(); + return first == '#' && second == '!'; + } + catch + { + return false; + } + } + + /// + /// 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). + /// + internal static ImmutableArray EvaluateDirectives( + ProjectInstance? project, + ImmutableArray directives, + SourceFile sourceFile, + ErrorReporter errorReporter) + { + if (directives.OfType().Any()) + { + 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; + } + + public void CreateProjectInstance( + ProjectCollection projectCollection, + ErrorReporter errorReporter, + out ProjectInstance project, + out ImmutableArray evaluatedDirectives, + ImmutableArray directives = default, + Action>? addGlobalProperties = null, + bool validateAllDirectives = false) + { + if (directives.IsDefault) + { + directives = FileLevelDirectiveHelpers.FindDirectives(EntryPointSourceFile, validateAllDirectives, errorReporter); + } + + project = CreateProjectInstance(projectCollection, directives, addGlobalProperties); + + evaluatedDirectives = EvaluateDirectives(project, directives, EntryPointSourceFile, errorReporter); + if (evaluatedDirectives != directives) + { + project = CreateProjectInstance(projectCollection, evaluatedDirectives, addGlobalProperties); + } + } + + private ProjectInstance CreateProjectInstance( + ProjectCollection projectCollection, + ImmutableArray directives, + Action>? addGlobalProperties = null) + { + var projectRoot = CreateProjectRootElement(projectCollection); + + var globalProperties = projectCollection.GlobalProperties; + if (addGlobalProperties is not null) + { + globalProperties = new Dictionary(projectCollection.GlobalProperties, StringComparer.OrdinalIgnoreCase); + addGlobalProperties(globalProperties); + } + + return ProjectInstance.FromProjectRootElement(projectRoot, new ProjectOptions + { + ProjectCollection = projectCollection, + GlobalProperties = globalProperties, + }); + + ProjectRootElement CreateProjectRootElement(ProjectCollection projectCollection) + { + var projectFileFullPath = GetVirtualProjectPath(EntryPointFileFullPath); + var projectFileWriter = new StringWriter(); + + WriteProjectFile( + projectFileWriter, + directives, + _defaultProperties, + isVirtualProject: true, + targetFilePath: 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; + } + } + + public static void WriteProjectFile( + TextWriter writer, + ImmutableArray directives, + IEnumerable<(string name, string value)> defaultProperties, + bool isVirtualProject, + string? targetFilePath = null, + string? artifactsPath = null, + bool includeRuntimeConfigInformation = true, + string? userSecretsId = null) + { + Debug.Assert(userSecretsId == null || !isVirtualProject); + + int processedDirectives = 0; + + var sdkDirectives = directives.OfType(); + var propertyDirectives = directives.OfType(); + var packageDirectives = directives.OfType(); + var projectDirectives = directives.OfType(); + + const string defaultSdkName = "Microsoft.NET.Sdk"; + string firstSdkName; + string? firstSdkVersion; + + if (sdkDirectives.FirstOrDefault() is { } firstSdk) + { + firstSdkName = firstSdk.Name; + firstSdkVersion = firstSdk.Version; + processedDirectives++; + } + else + { + firstSdkName = defaultSdkName; + firstSdkVersion = null; + } + + if (isVirtualProject) + { + Debug.Assert(!string.IsNullOrWhiteSpace(artifactsPath)); + + // Note that ArtifactsPath needs to be specified before Sdk.props + // (usually it's recommended to specify it in Directory.Build.props + // but importing Sdk.props manually afterwards also works). + writer.WriteLine($""" + + + + false + {EscapeValue(artifactsPath)} + artifacts/$(MSBuildProjectName) + artifacts/$(MSBuildProjectName) + true + false + true + """); + + // Only set these to false when using the default SDK with no additional SDKs + // to avoid including .resx and other files that are typically not expected in simple file-based apps. + // When other SDKs are used (e.g., Microsoft.NET.Sdk.Web), keep the default behavior. + bool usingOnlyDefaultSdk = firstSdkName == defaultSdkName && sdkDirectives.Count() <= 1; + if (usingOnlyDefaultSdk) + { + writer.WriteLine($""" + false + false + """); + } + + // Write default properties before importing SDKs so they can be overridden by SDKs + // (and implicit build files which are imported by the default .NET SDK). + foreach (var (name, value) in defaultProperties) + { + writer.WriteLine($""" + <{name}>{EscapeValue(value)} + """); + } + + writer.WriteLine($""" + + + + + + + """); + + if (firstSdkVersion is null) + { + writer.WriteLine($""" + + """); + } + else + { + writer.WriteLine($""" + + """); + } + } + else + { + string slashDelimited = firstSdkVersion is null + ? firstSdkName + : $"{firstSdkName}/{firstSdkVersion}"; + writer.WriteLine($""" + + + """); + } + + foreach (var sdk in sdkDirectives.Skip(1)) + { + if (isVirtualProject) + { + WriteImport(writer, "Sdk.props", sdk); + } + else if (sdk.Version is null) + { + writer.WriteLine($""" + + """); + } + else + { + writer.WriteLine($""" + + """); + } + + processedDirectives++; + } + + if (isVirtualProject || processedDirectives > 1) + { + writer.WriteLine(); + } + + // Write default and custom properties. + { + writer.WriteLine(""" + + """); + + // First write the default properties except those specified by the user. + if (!isVirtualProject) + { + var customPropertyNames = propertyDirectives + .Select(static d => d.Name) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var (name, value) in defaultProperties) + { + if (!customPropertyNames.Contains(name)) + { + writer.WriteLine($""" + <{name}>{EscapeValue(value)} + """); + } + } + + if (userSecretsId != null && !customPropertyNames.Contains("UserSecretsId")) + { + writer.WriteLine($""" + {EscapeValue(userSecretsId)} + """); + } + } + + // Write custom properties. + foreach (var property in propertyDirectives) + { + writer.WriteLine($""" + <{property.Name}>{EscapeValue(property.Value)} + """); + + processedDirectives++; + } + + // Write virtual-only properties which cannot be overridden. + if (isVirtualProject) + { + writer.WriteLine(""" + false + $(Features);FileBasedProgram + """); + } + + writer.WriteLine(""" + + + """); + } + + if (packageDirectives.Any()) + { + writer.WriteLine(""" + + """); + + foreach (var package in packageDirectives) + { + if (package.Version is null) + { + writer.WriteLine($""" + + """); + } + else + { + writer.WriteLine($""" + + """); + } + + processedDirectives++; + } + + writer.WriteLine(""" + + + """); + } + + if (projectDirectives.Any()) + { + writer.WriteLine(""" + + """); + + foreach (var projectReference in projectDirectives) + { + writer.WriteLine($""" + + """); + + processedDirectives++; + } + + writer.WriteLine(""" + + + """); + } + + Debug.Assert(processedDirectives + directives.OfType().Count() == directives.Length); + + if (isVirtualProject) + { + Debug.Assert(targetFilePath 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 + writer.WriteLine($""" + + + + + """); + + if (includeRuntimeConfigInformation) + { + var targetDirectory = Path.GetDirectoryName(targetFilePath) ?? ""; + writer.WriteLine($""" + + + + + + """); + } + + foreach (var sdk in sdkDirectives) + { + WriteImport(writer, "Sdk.targets", sdk); + } + + if (!sdkDirectives.Any()) + { + Debug.Assert(firstSdkName == defaultSdkName && firstSdkVersion == null); + writer.WriteLine($""" + + """); + } + + writer.WriteLine(); + } + + writer.WriteLine(""" + + """); + + static string EscapeValue(string value) => SecurityElement.Escape(value); + + static void WriteImport(TextWriter writer, string project, CSharpDirective.Sdk sdk) + { + if (sdk.Version is null) + { + writer.WriteLine($""" + + """); + } + else + { + writer.WriteLine($""" + + """); + } + } + } + + 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 new file mode 100644 index 00000000000..6b4ff5bb55d --- /dev/null +++ b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.cs.xlf @@ -0,0 +1,68 @@ + + + + + + (Default) + (výchozí) + + + + An error was encountered when reading '{0}': {1} + Při čtení {0} došlo k chybě: {1} + {0} is file path. {1} is exception message. + + + There are several launch profiles with case-sensitive names, which isn't permitted: +{0} +Make the profile names distinct. + Existuje několik spouštěcích profilů s názvy rozlišujícími malá a velká písmena, což není povoleno: +{0} +Nastavte odlišné názvy profilů. + + + + A launch profile with the name '{0}' doesn't exist. + Profil spuštění s názvem {0} neexistuje. + + + + The launch profile type '{0}' is not supported. + Typ profilu spuštění {0} se nepodporuje. + + + + A profile with the specified name isn't a valid JSON object. + Profil se zadaným názvem není platný objekt JSON. + + + + The 'profiles' property of the launch settings document is not a JSON object. + Vlastnost profiles v dokumentu nastavení spuštění není objektem JSON. + + + + Path '{0}' specified in '{1}' is invalid. + Cesta {0} zadaná v: {1} je neplatná. + + + + Cannot use launch profile '{0}' because the launch settings file could not be located. Locations tried: +{1} + Nelze použít profil spouštění {0}, protože soubor nastavení spouštění nebyl nalezen. Vyzkoušená umístění: +{1} + + + + Warning: Settings from '{0}' are not used because '{1}' has precedence. + Upozornění: Nastavení z {0} nejsou použita, protože {1} má přednost. + {0} is an app.run.json file path. {1} is a launchSettings.json file path. + + + A usable launch profile could not be located. + Nenašel se použitelný profil spuštění. + + + + + \ No newline at end of file diff --git a/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.de.xlf b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.de.xlf new file mode 100644 index 00000000000..93d3cd73331 --- /dev/null +++ b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.de.xlf @@ -0,0 +1,68 @@ + + + + + + (Default) + (Standard) + + + + An error was encountered when reading '{0}': {1} + Beim Lesen von „{0}“ ist ein Fehler aufgetreten: {1} + {0} is file path. {1} is exception message. + + + There are several launch profiles with case-sensitive names, which isn't permitted: +{0} +Make the profile names distinct. + Es gibt mehrere Startprofile mit Namen, bei denen die Groß-/Kleinschreibung beachtet wird, was nicht zulässig ist: +{0} +Erstellen Sie eindeutige Profilnamen. + + + + A launch profile with the name '{0}' doesn't exist. + Es ist kein Startprofil mit dem Namen "{0}" vorhanden. + + + + The launch profile type '{0}' is not supported. + Der Startprofiltyp "{0}" wird nicht unterstützt. + + + + A profile with the specified name isn't a valid JSON object. + Ein Profil mit dem angegebenen Namen ist kein gültiges JSON-Objekt. + + + + The 'profiles' property of the launch settings document is not a JSON object. + Die Eigenschaft "Profile" des Starteigenschaftendokuments ist kein JSON-Objekt. + + + + Path '{0}' specified in '{1}' is invalid. + Der in „{1}“ angegebene Pfad „{0}“ ist ungültig. + + + + Cannot use launch profile '{0}' because the launch settings file could not be located. Locations tried: +{1} + Das Startprofil „{0}“ kann nicht verwendet werden, da die Datei mit den Starteinstellungen nicht gefunden wurde. Versuchte Standorte: +{1} + + + + Warning: Settings from '{0}' are not used because '{1}' has precedence. + Warnung: Einstellungen von „{0}“ werden nicht verwendet, weil „{1}“ Vorrang hat. + {0} is an app.run.json file path. {1} is a launchSettings.json file path. + + + A usable launch profile could not be located. + Es wurde kein verwendbares Startprofil gefunden. + + + + + \ No newline at end of file diff --git a/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.es.xlf b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.es.xlf new file mode 100644 index 00000000000..5e37b3de70a --- /dev/null +++ b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.es.xlf @@ -0,0 +1,68 @@ + + + + + + (Default) + (Predeterminada) + + + + An error was encountered when reading '{0}': {1} + Error al leer '{0}': {1} + {0} is file path. {1} is exception message. + + + There are several launch profiles with case-sensitive names, which isn't permitted: +{0} +Make the profile names distinct. + Hay varios perfiles de inicio con nombres que distinguen mayúsculas de minúsculas, lo que no está permitido: +{0} +Defina nombres de perfiles distintos. + + + + A launch profile with the name '{0}' doesn't exist. + No existe ningún perfil de inicio con el nombre "{0}". + + + + The launch profile type '{0}' is not supported. + No se admite el tipo de perfil de inicio "{0}". + + + + A profile with the specified name isn't a valid JSON object. + Un perfil con el nombre especificado no es un objeto JSON válido. + + + + The 'profiles' property of the launch settings document is not a JSON object. + La propiedad "profiles" del documento de configuración de inicio no es un objeto JSON. + + + + Path '{0}' specified in '{1}' is invalid. + La ruta de acceso '{0}' especificada en '{1}' no es válida. + + + + Cannot use launch profile '{0}' because the launch settings file could not be located. Locations tried: +{1} + No se puede usar el perfil de inicio '{0}' porque no se pudo localizar el archivo de configuración de inicio. Ubicaciones probadas: +{1} + + + + Warning: Settings from '{0}' are not used because '{1}' has precedence. + Advertencia: la configuración de '{0}' no se utiliza porque '{1}' tiene prioridad. + {0} is an app.run.json file path. {1} is a launchSettings.json file path. + + + A usable launch profile could not be located. + No se ha podido encontrar un perfil de inicio que se pueda usar. + + + + + \ No newline at end of file diff --git a/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.fr.xlf b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.fr.xlf new file mode 100644 index 00000000000..6440e8fe7f3 --- /dev/null +++ b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.fr.xlf @@ -0,0 +1,68 @@ + + + + + + (Default) + (Par défaut) + + + + An error was encountered when reading '{0}': {1} + Une erreur est survenue pendant la lecture de « {0} » : {1} + {0} is file path. {1} is exception message. + + + There are several launch profiles with case-sensitive names, which isn't permitted: +{0} +Make the profile names distinct. + Il existe plusieurs profils de lancement avec des noms qui respectent la casse, ce qui n’est pas autorisé : +{0} +Faites en sorte que les noms de profil soient distincts. + + + + A launch profile with the name '{0}' doesn't exist. + Un profil de lancement avec le nom '{0}' n'existe pas. + + + + The launch profile type '{0}' is not supported. + Le type de profil de lancement '{0}' n'est pas pris en charge. + + + + A profile with the specified name isn't a valid JSON object. + Un profil avec le nom spécifié n'est pas un objet JSON valide. + + + + The 'profiles' property of the launch settings document is not a JSON object. + La propriété 'profiles' du document de paramètres de lancement n'est pas un objet JSON. + + + + Path '{0}' specified in '{1}' is invalid. + Le chemin d’accès « {0} » spécifié dans « {1} » n’est pas valide. + + + + Cannot use launch profile '{0}' because the launch settings file could not be located. Locations tried: +{1} + Impossible d’utiliser le profil de lancement « {0} », car le fichier de paramètres de lancement est introuvable. Emplacements essayés : +{1} + + + + Warning: Settings from '{0}' are not used because '{1}' has precedence. + Avertissement : les paramètres de « {0} » ne sont pas utilisés, car « {1} » a la priorité. + {0} is an app.run.json file path. {1} is a launchSettings.json file path. + + + A usable launch profile could not be located. + Impossible de localiser un profil de lancement utilisable. + + + + + \ No newline at end of file diff --git a/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.it.xlf b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.it.xlf new file mode 100644 index 00000000000..37441888832 --- /dev/null +++ b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.it.xlf @@ -0,0 +1,68 @@ + + + + + + (Default) + (Predefinita) + + + + An error was encountered when reading '{0}': {1} + Si è verificato un errore durante la lettura di "{0}": {1} + {0} is file path. {1} is exception message. + + + There are several launch profiles with case-sensitive names, which isn't permitted: +{0} +Make the profile names distinct. + Esistono diversi profili di avvio con nomi con distinzione tra maiuscole e minuscole, che non sono consentiti: +{0} +Rendi distinti i nomi profilo. + + + + A launch profile with the name '{0}' doesn't exist. + Non esiste un profilo di avvio con il nome '{0}'. + + + + The launch profile type '{0}' is not supported. + Il tipo '{0}' del profilo di avvio non è supportato. + + + + A profile with the specified name isn't a valid JSON object. + Un profilo con il nome specificato non è un oggetto JSON valido. + + + + The 'profiles' property of the launch settings document is not a JSON object. + La proprietà 'profiles' del documento delle impostazioni di avvio non è un oggetto JSON. + + + + Path '{0}' specified in '{1}' is invalid. + Il percorso '{0}' specificato in '{1}' non è valido. + + + + Cannot use launch profile '{0}' because the launch settings file could not be located. Locations tried: +{1} + Non è possibile usare il profilo di avvio '{0}' perché il file delle impostazioni di avvio non è stato trovato. Posizioni tentate: +{1} + + + + Warning: Settings from '{0}' are not used because '{1}' has precedence. + Avviso: le impostazioni di "{0}" non vengono usate perché "{1}" ha la precedenza. + {0} is an app.run.json file path. {1} is a launchSettings.json file path. + + + A usable launch profile could not be located. + Non è stato trovato alcun profilo di avvio utilizzabile. + + + + + \ No newline at end of file diff --git a/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.ja.xlf b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.ja.xlf new file mode 100644 index 00000000000..7a932cd4791 --- /dev/null +++ b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.ja.xlf @@ -0,0 +1,68 @@ + + + + + + (Default) + (既定) + + + + An error was encountered when reading '{0}': {1} + '{0}' の読み取り中にエラーが発生しました: {1} + {0} is file path. {1} is exception message. + + + There are several launch profiles with case-sensitive names, which isn't permitted: +{0} +Make the profile names distinct. + 大文字と小文字が区別される名前の起動プロファイルがいくつかありますが、これは許可されていません: +{0} +プロファイル名を区別できるようにしてください。 + + + + A launch profile with the name '{0}' doesn't exist. + '{0} ' という名前の起動プロファイルは存在しません。 + + + + The launch profile type '{0}' is not supported. + 起動プロファイルの種類 '{0}' はサポートされていません。 + + + + A profile with the specified name isn't a valid JSON object. + 指定された名前のプロファイルは、有効な JSON オブジェクトではありません。 + + + + The 'profiles' property of the launch settings document is not a JSON object. + 起動設定のドキュメントの 'profiles' プロパティが JSON オブジェクトではありません。 + + + + Path '{0}' specified in '{1}' is invalid. + '{1}' で指定されたパス '{0}' が無効です。 + + + + Cannot use launch profile '{0}' because the launch settings file could not be located. Locations tried: +{1} + 起動設定ファイルが見つからないため、起動プロファイル '{0}' を使用できません。試行された場所: +{1} + + + + Warning: Settings from '{0}' are not used because '{1}' has precedence. + 警告: '{1}' が優先されるため、'{0}' の設定は使用されません。 + {0} is an app.run.json file path. {1} is a launchSettings.json file path. + + + A usable launch profile could not be located. + 使用可能な起動プロファイルが見つかりませんでした。 + + + + + \ No newline at end of file diff --git a/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.ko.xlf b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.ko.xlf new file mode 100644 index 00000000000..738033adb53 --- /dev/null +++ b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.ko.xlf @@ -0,0 +1,68 @@ + + + + + + (Default) + (기본값) + + + + An error was encountered when reading '{0}': {1} + '{0}'을 읽는 동안 오류가 발생했습니다. {1} + {0} is file path. {1} is exception message. + + + There are several launch profiles with case-sensitive names, which isn't permitted: +{0} +Make the profile names distinct. + 대/소문자를 구분하는 이름을 가진 여러 시작 프로필이 있으며 이는 허용되지 않습니다. +{0} +고유한 프로필 이름을 사용하세요. + + + + A launch profile with the name '{0}' doesn't exist. + 이름이 '{0}'인 시작 프로필이 없습니다. + + + + The launch profile type '{0}' is not supported. + 시작 프로필 형식 '{0}'은(는) 지원되지 않습니다. + + + + A profile with the specified name isn't a valid JSON object. + 지정된 이름의 프로필은 유효한 JSON 개체가 아닙니다. + + + + The 'profiles' property of the launch settings document is not a JSON object. + 시작 설정 문서의 '프로필' 속성이 JSON 개체가 아닙니다. + + + + Path '{0}' specified in '{1}' is invalid. + '{1}'에 지정된 경로 '{0}'이(가) 잘못되었습니다. + + + + Cannot use launch profile '{0}' because the launch settings file could not be located. Locations tried: +{1} + 시작 설정 파일을 찾을 수 없으므로 시작 프로필 '{0}'을(를) 사용할 수 없습니다. 시도한 위치: +{1} + + + + Warning: Settings from '{0}' are not used because '{1}' has precedence. + 경고: '{0}'의 설정은 '{1}'이(가) 우선하므로 사용되지 않습니다. + {0} is an app.run.json file path. {1} is a launchSettings.json file path. + + + A usable launch profile could not be located. + 사용할 수 있는 시작 프로필을 찾을 수 없습니다. + + + + + \ No newline at end of file diff --git a/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.pl.xlf b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.pl.xlf new file mode 100644 index 00000000000..cfd4a8d9cf5 --- /dev/null +++ b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.pl.xlf @@ -0,0 +1,68 @@ + + + + + + (Default) + (Domyślne) + + + + An error was encountered when reading '{0}': {1} + Napotkano błąd podczas odczytywania elementu „{0}”: {1} + {0} is file path. {1} is exception message. + + + There are several launch profiles with case-sensitive names, which isn't permitted: +{0} +Make the profile names distinct. + Istnieje kilka profilów uruchamiania z nazwami uwzględniającymi wielkość liter, co jest niedozwolone: +{0} +Rozróżnij nazwy profilów. + + + + A launch profile with the name '{0}' doesn't exist. + Profil uruchamiania o nazwie „{0}” nie istnieje. + + + + The launch profile type '{0}' is not supported. + Typ profilu uruchamiania „{0}” nie jest obsługiwany. + + + + A profile with the specified name isn't a valid JSON object. + Profil o określonej nazwie nie jest prawidłowym obiektem JSON. + + + + The 'profiles' property of the launch settings document is not a JSON object. + Właściwość „profiles” dokumentu ustawień uruchamiania nie jest obiektem JSON. + + + + Path '{0}' specified in '{1}' is invalid. + Ścieżka „{0}” określona w „{1}” jest nieprawidłowa. + + + + Cannot use launch profile '{0}' because the launch settings file could not be located. Locations tried: +{1} + Nie można użyć profilu uruchamiania „{0}”, ponieważ nie można zlokalizować pliku ustawień uruchamiania. Wypróbowano lokalizacje: +{1} + + + + Warning: Settings from '{0}' are not used because '{1}' has precedence. + Ostrzeżenie: ustawienia z „{0}” nie są używane, ponieważ element „{1}” ma pierwszeństwo. + {0} is an app.run.json file path. {1} is a launchSettings.json file path. + + + A usable launch profile could not be located. + Nie można odnaleźć nadającego się do użytku profilu uruchamiania. + + + + + \ No newline at end of file diff --git a/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.pt-BR.xlf b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.pt-BR.xlf new file mode 100644 index 00000000000..68e6a06dfb3 --- /dev/null +++ b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.pt-BR.xlf @@ -0,0 +1,68 @@ + + + + + + (Default) + (Padrão) + + + + An error was encountered when reading '{0}': {1} + Ocorreu um erro ao ler '{0}': {1} + {0} is file path. {1} is exception message. + + + There are several launch profiles with case-sensitive names, which isn't permitted: +{0} +Make the profile names distinct. + Existem vários perfis de inicialização com nomes que diferenciam maiúsculas de minúsculas, o que não é permitido: +{0} +Diferencie os nomes dos perfis. + + + + A launch profile with the name '{0}' doesn't exist. + Um perfil de lançamento com o nome '{0}' não existe. + + + + The launch profile type '{0}' is not supported. + Não há suporte para o tipo de perfil de inicialização '{0}'. + + + + A profile with the specified name isn't a valid JSON object. + Um perfil com o nome especificado não é um objeto JSON válido. + + + + The 'profiles' property of the launch settings document is not a JSON object. + A propriedade 'perfis' do documento de configurações de inicialização não é um objeto JSON. + + + + Path '{0}' specified in '{1}' is invalid. + O caminho ''{0}'' especificado em ''{1}'' é inválido. + + + + Cannot use launch profile '{0}' because the launch settings file could not be located. Locations tried: +{1} + Não é possível usar o perfil de inicialização ''{0}'' porque o arquivo de configurações de inicialização não pôde ser localizado. Localizações tentadas: +{1} + + + + Warning: Settings from '{0}' are not used because '{1}' has precedence. + Aviso: As configurações de '{0}' não são utilizadas porque '{1}' tem precedência. + {0} is an app.run.json file path. {1} is a launchSettings.json file path. + + + A usable launch profile could not be located. + Um perfil de inicialização utilizável não pôde ser localizado. + + + + + \ No newline at end of file diff --git a/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.ru.xlf b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.ru.xlf new file mode 100644 index 00000000000..4261e1e952e --- /dev/null +++ b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.ru.xlf @@ -0,0 +1,68 @@ + + + + + + (Default) + (По умолчанию) + + + + An error was encountered when reading '{0}': {1} + Произошла ошибка при чтении "{0}": {1} + {0} is file path. {1} is exception message. + + + There are several launch profiles with case-sensitive names, which isn't permitted: +{0} +Make the profile names distinct. + Существует несколько профилей, имена которых различаются только регистром, что запрещено: +{0} +Сделайте имена профилей разными. + + + + A launch profile with the name '{0}' doesn't exist. + Профиль запуска с именем "{0}" не существует. + + + + The launch profile type '{0}' is not supported. + Тип профиля запуска "{0}" не поддерживается. + + + + A profile with the specified name isn't a valid JSON object. + Профиль с указанным именем не является допустимым объектом JSON. + + + + The 'profiles' property of the launch settings document is not a JSON object. + Свойство "profiles" документа параметров запуска не является объектом JSON. + + + + Path '{0}' specified in '{1}' is invalid. + Путь "{0}", указанный в "{1}", недействителен. + + + + Cannot use launch profile '{0}' because the launch settings file could not be located. Locations tried: +{1} + Не удается использовать профиль запуска "{0}", так как не удалось найти файл параметров запуска. Попробованные расположения: +{1} + + + + Warning: Settings from '{0}' are not used because '{1}' has precedence. + Внимание! Параметры из "{0}" не используются, так как "{1}" обладает приоритетом. + {0} is an app.run.json file path. {1} is a launchSettings.json file path. + + + A usable launch profile could not be located. + Не удалось найти подходящий для использования профиль запуска. + + + + + \ No newline at end of file diff --git a/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.tr.xlf b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.tr.xlf new file mode 100644 index 00000000000..6bd01ca3bd4 --- /dev/null +++ b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.tr.xlf @@ -0,0 +1,68 @@ + + + + + + (Default) + (Varsayılan) + + + + An error was encountered when reading '{0}': {1} + ‘{0}’ okunurken bir hata oluştu: {1} + {0} is file path. {1} is exception message. + + + There are several launch profiles with case-sensitive names, which isn't permitted: +{0} +Make the profile names distinct. + Adı büyük/küçük harfe duyarlı olan birkaç başlatma profili var ama bu duruma izin verilmiyor: +{0} +Profil adlarının birbirinden farklı olmasını sağlayın. + + + + A launch profile with the name '{0}' doesn't exist. + '{0}' adlı bir başlatma profili yok. + + + + The launch profile type '{0}' is not supported. + '{0}' başlatma profili türü desteklenmiyor. + + + + A profile with the specified name isn't a valid JSON object. + Belirtilen adla bir profil, geçerli bir JSON nesnesi değil. + + + + The 'profiles' property of the launch settings document is not a JSON object. + Başlatma ayarları belgesinin 'profiles' özelliği bir JSON nesnesi değil. + + + + Path '{0}' specified in '{1}' is invalid. + '{1}' içinde belirtilen '{0}' yolu geçersiz. + + + + Cannot use launch profile '{0}' because the launch settings file could not be located. Locations tried: +{1} + Başlatma ayarları dosyası bulunamadığı için ‘{0}’ başlatma profilini kullanılamıyor. Denenen konumlar: +{1} + + + + Warning: Settings from '{0}' are not used because '{1}' has precedence. + Uyarı: ‘{1}’ öncelikli olduğu için ‘{0}’ ayarları kullanılmaz. + {0} is an app.run.json file path. {1} is a launchSettings.json file path. + + + A usable launch profile could not be located. + Kullanılabilir bir başlatma profili bulunamadı. + + + + + \ No newline at end of file diff --git a/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.zh-Hans.xlf b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.zh-Hans.xlf new file mode 100644 index 00000000000..9aded33cb3c --- /dev/null +++ b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.zh-Hans.xlf @@ -0,0 +1,68 @@ + + + + + + (Default) + (默认值) + + + + An error was encountered when reading '{0}': {1} + 读取 ‘{0}’ 时遇到错误: {1} + {0} is file path. {1} is exception message. + + + There are several launch profiles with case-sensitive names, which isn't permitted: +{0} +Make the profile names distinct. + 存在多个名称区分大小写的启动配置文件,这是不允许的: +{0} +将配置文件名称设为可区分的名称。 + + + + A launch profile with the name '{0}' doesn't exist. + 名为“{0}”的启动配置文件不存在。 + + + + The launch profile type '{0}' is not supported. + 不支持启动配置文件类型“{0}”。 + + + + A profile with the specified name isn't a valid JSON object. + 具有指定名称的配置文件不是有效的 JSON 对象。 + + + + The 'profiles' property of the launch settings document is not a JSON object. + 启动设置文档的“profiles”属性不是 JSON 对象。 + + + + Path '{0}' specified in '{1}' is invalid. + 在“{1}”中指定的路径“{0}”无效。 + + + + Cannot use launch profile '{0}' because the launch settings file could not be located. Locations tried: +{1} + 无法使用启动配置文件“{0}”,因为找不到启动设置文件。已尝试的位置: +{1} + + + + Warning: Settings from '{0}' are not used because '{1}' has precedence. + 警告: 不使用 ‘{0}’ 中的设置,因为 ‘{1}’ 具有优先级。 + {0} is an app.run.json file path. {1} is a launchSettings.json file path. + + + A usable launch profile could not be located. + 找不到可用的启动配置文件。 + + + + + \ No newline at end of file diff --git a/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.zh-Hant.xlf b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.zh-Hant.xlf new file mode 100644 index 00000000000..8650ca7c10d --- /dev/null +++ b/src/WatchPrototype/Microsoft.DotNet.ProjectTools/xlf/Resources.zh-Hant.xlf @@ -0,0 +1,68 @@ + + + + + + (Default) + (預設) + + + + An error was encountered when reading '{0}': {1} + 讀取「{0}」時發生錯誤: {1} + {0} is file path. {1} is exception message. + + + There are several launch profiles with case-sensitive names, which isn't permitted: +{0} +Make the profile names distinct. + 數個啟動設定檔具有區分大小寫的名稱,這並不受允許: +{0} +請讓設定檔名稱相異。 + + + + A launch profile with the name '{0}' doesn't exist. + 名稱為 '{0}' 的啟動設定檔不存在。 + + + + The launch profile type '{0}' is not supported. + 不支援啟動設定檔類型 '{0}'。 + + + + A profile with the specified name isn't a valid JSON object. + 具有指定名稱的設定檔不是有效的 JSON 物件。 + + + + The 'profiles' property of the launch settings document is not a JSON object. + 啟動設定文件的 'profiles' 屬性並非 JSON 物件。 + + + + Path '{0}' specified in '{1}' is invalid. + '{1}' 中指定的路徑 '{0}' 無效。 + + + + Cannot use launch profile '{0}' because the launch settings file could not be located. Locations tried: +{1} + 無法使用啟動設定檔 '{0}',因為找不到啟動設定檔案。嘗試的位置: +{1} + + + + Warning: Settings from '{0}' are not used because '{1}' has precedence. + 警告: 來自 '{0}' 的設定未被使用,因為 '{1}' 具有優先順序。 + {0} is an app.run.json file path. {1} is a launchSettings.json file path. + + + A usable launch profile could not be located. + 找不到可用的啟動設定檔。 + + + + + \ No newline at end of file diff --git a/src/WatchPrototype/Watch.Aspire/DotNetWatchLauncher.cs b/src/WatchPrototype/Watch.Aspire/DotNetWatchLauncher.cs new file mode 100644 index 00000000000..121b600ef50 --- /dev/null +++ b/src/WatchPrototype/Watch.Aspire/DotNetWatchLauncher.cs @@ -0,0 +1,86 @@ +// 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 new file mode 100644 index 00000000000..497078f9322 --- /dev/null +++ b/src/WatchPrototype/Watch.Aspire/DotNetWatchOptions.cs @@ -0,0 +1,96 @@ +// 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/Microsoft.DotNet.HotReload.Watch.Aspire.csproj b/src/WatchPrototype/Watch.Aspire/Microsoft.DotNet.HotReload.Watch.Aspire.csproj new file mode 100644 index 00000000000..fb08c258b80 --- /dev/null +++ b/src/WatchPrototype/Watch.Aspire/Microsoft.DotNet.HotReload.Watch.Aspire.csproj @@ -0,0 +1,40 @@ + + + + + $(SdkTargetFramework) + MicrosoftAspNetCore + Exe + Microsoft.DotNet.Watch + true + + + true + true + true + Microsoft.DotNet.HotReload.Watch.Aspire + + A supporting package for Aspire CLI: + https://github.com/dotnet/aspire + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/WatchPrototype/Watch.Aspire/Program.cs b/src/WatchPrototype/Watch.Aspire/Program.cs new file mode 100644 index 00000000000..37de5ceca57 --- /dev/null +++ b/src/WatchPrototype/Watch.Aspire/Program.cs @@ -0,0 +1,12 @@ +using Microsoft.Build.Locator; +using Microsoft.DotNet.Watch; + +if (!DotNetWatchOptions.TryParse(args, out var options)) +{ + return -1; +} + +MSBuildLocator.RegisterMSBuildPath(options.SdkDirectory); + +var workingDirectory = Directory.GetCurrentDirectory(); +return await DotNetWatchLauncher.RunAsync(workingDirectory, options) ? 0 : 1; diff --git a/src/WatchPrototype/Watch.Aspire/Properties/AssemblyInfo.cs b/src/WatchPrototype/Watch.Aspire/Properties/AssemblyInfo.cs new file mode 100644 index 00000000000..74a1e15ab7a --- /dev/null +++ b/src/WatchPrototype/Watch.Aspire/Properties/AssemblyInfo.cs @@ -0,0 +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.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Microsoft.DotNet.HotReload.Watch.Aspire.Tests, PublicKey = 0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] +[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2, PublicKey=0024000004800000940000000602000000240000525341310004000001000100c547cac37abd99c8db225ef2f6c8a3602f3b3606cc9891605d02baa56104f4cfc0734aa39b93bf7852f7d9266654753cc297e7d2edfe0bac1cdcf9f717241550e0a7b191195b7667bb4f64bcb8e2121380fd1d9d46ad2d92d2d15605093924cceaf74c4861eff62abf69b9291ed0a340e113be11e6a7d3113e92484cf7045cc7")] diff --git a/src/WatchPrototype/Watch.slnx b/src/WatchPrototype/Watch.slnx new file mode 100644 index 00000000000..dd498b67ed1 --- /dev/null +++ b/src/WatchPrototype/Watch.slnx @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/WatchPrototype/Watch/AppModels/BlazorWebAssemblyAppModel.cs b/src/WatchPrototype/Watch/AppModels/BlazorWebAssemblyAppModel.cs new file mode 100644 index 00000000000..ea3480f979f --- /dev/null +++ b/src/WatchPrototype/Watch/AppModels/BlazorWebAssemblyAppModel.cs @@ -0,0 +1,27 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Immutable; +using System.Diagnostics; +using Microsoft.Build.Graph; +using Microsoft.DotNet.HotReload; +using Microsoft.Extensions.Logging; + +namespace Microsoft.DotNet.Watch; + +/// +/// Blazor client-only WebAssembly app. +/// +internal sealed class BlazorWebAssemblyAppModel(DotNetWatchContext context, ProjectGraphNode clientProject) + : WebApplicationAppModel(context) +{ + public override ProjectGraphNode LaunchingProject => clientProject; + + public override bool RequiresBrowserRefresh => true; + + protected override HotReloadClients CreateClients(ILogger clientLogger, ILogger agentLogger, BrowserRefreshServer? browserRefreshServer) + { + Debug.Assert(browserRefreshServer != null); + return new(CreateWebAssemblyClient(clientLogger, agentLogger, browserRefreshServer, clientProject), browserRefreshServer); + } +} diff --git a/src/WatchPrototype/Watch/AppModels/BlazorWebAssemblyHostedAppModel.cs b/src/WatchPrototype/Watch/AppModels/BlazorWebAssemblyHostedAppModel.cs new file mode 100644 index 00000000000..12108762305 --- /dev/null +++ b/src/WatchPrototype/Watch/AppModels/BlazorWebAssemblyHostedAppModel.cs @@ -0,0 +1,35 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Immutable; +using System.Diagnostics; +using Microsoft.Build.Graph; +using Microsoft.DotNet.HotReload; +using Microsoft.Extensions.Logging; + +namespace Microsoft.DotNet.Watch; + +/// +/// Blazor WebAssembly app hosted by an ASP.NET Core app. +/// App has a client and server projects and deltas are applied to both processes. +/// Agent is injected into the server process. The client process is updated via WebSocketScriptInjection.js injected into the browser. +/// +internal sealed class BlazorWebAssemblyHostedAppModel(DotNetWatchContext context, ProjectGraphNode clientProject, ProjectGraphNode serverProject) + : WebApplicationAppModel(context) +{ + public override ProjectGraphNode LaunchingProject => serverProject; + + public override bool RequiresBrowserRefresh => true; + + protected override HotReloadClients CreateClients(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); + } +} diff --git a/src/WatchPrototype/Watch/AppModels/DefaultAppModel.cs b/src/WatchPrototype/Watch/AppModels/DefaultAppModel.cs new file mode 100644 index 00000000000..300236d7250 --- /dev/null +++ b/src/WatchPrototype/Watch/AppModels/DefaultAppModel.cs @@ -0,0 +1,17 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Build.Graph; +using Microsoft.DotNet.HotReload; +using Microsoft.Extensions.Logging; + +namespace Microsoft.DotNet.Watch; + +/// +/// Default model. +/// +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)); +} diff --git a/src/WatchPrototype/Watch/AppModels/HotReloadAppModel.cs b/src/WatchPrototype/Watch/AppModels/HotReloadAppModel.cs new file mode 100644 index 00000000000..7a205a8d1fc --- /dev/null +++ b/src/WatchPrototype/Watch/AppModels/HotReloadAppModel.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. + +using Microsoft.Build.Graph; +using Microsoft.DotNet.HotReload; +using Microsoft.Extensions.Logging; + +namespace Microsoft.DotNet.Watch; + +internal abstract partial class HotReloadAppModel() +{ + public abstract ValueTask TryCreateClientsAsync(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"); + + public static string GetStartupHookPath(ProjectGraphNode project) + { + var hookTargetFramework = project.GetTargetFrameworkVersion() is { Major: >= 10 } ? "net10.0" : "net6.0"; + return GetInjectedAssemblyPath(hookTargetFramework, "Microsoft.Extensions.DotNetDeltaApplier"); + } + + public static HotReloadAppModel InferFromProject(DotNetWatchContext context, ProjectGraphNode projectNode) + { + var capabilities = projectNode.GetCapabilities(); + + if (capabilities.Contains(ProjectCapability.WebAssembly)) + { + context.Logger.Log(MessageDescriptor.ApplicationKind_BlazorWebAssembly); + return new BlazorWebAssemblyAppModel(context, clientProject: projectNode); + } + + if (capabilities.Contains(ProjectCapability.AspNetCore)) + { + if (projectNode.GetDescendantsAndSelf().FirstOrDefault(static p => p.GetCapabilities().Contains(ProjectCapability.WebAssembly)) is { } clientProject) + { + context.Logger.Log(MessageDescriptor.ApplicationKind_BlazorHosted, projectNode.ProjectInstance.FullPath, clientProject.ProjectInstance.FullPath); + return new BlazorWebAssemblyHostedAppModel(context, clientProject: clientProject, serverProject: projectNode); + } + + context.Logger.Log(MessageDescriptor.ApplicationKind_WebApplication); + return new WebServerAppModel(context, serverProject: projectNode); + } + + context.Logger.Log(MessageDescriptor.ApplicationKind_Default); + return new DefaultAppModel(projectNode); + } +} diff --git a/src/WatchPrototype/Watch/AppModels/WebApplicationAppModel.cs b/src/WatchPrototype/Watch/AppModels/WebApplicationAppModel.cs new file mode 100644 index 00000000000..0f1fbb74d5d --- /dev/null +++ b/src/WatchPrototype/Watch/AppModels/WebApplicationAppModel.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 Microsoft.Build.Graph; +using Microsoft.DotNet.HotReload; +using Microsoft.Extensions.Logging; + +namespace Microsoft.DotNet.Watch; + +internal abstract class WebApplicationAppModel(DotNetWatchContext context) : HotReloadAppModel +{ + // This needs to be in sync with the version BrowserRefreshMiddleware is compiled against. + private static readonly Version s_minimumSupportedVersion = Versions.Version6_0; + private const string MiddlewareTargetFramework = "net6.0"; + + public DotNetWatchContext Context => context; + + public abstract bool RequiresBrowserRefresh { get; } + + /// + /// Project that's used for launching the application. + /// + public abstract ProjectGraphNode LaunchingProject { get; } + + protected abstract HotReloadClients CreateClients(ILogger clientLogger, ILogger agentLogger, BrowserRefreshServer? browserRefreshServer); + + public async sealed override ValueTask TryCreateClientsAsync(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); + } + + protected WebAssemblyHotReloadClient CreateWebAssemblyClient(ILogger clientLogger, ILogger agentLogger, BrowserRefreshServer browserRefreshServer, ProjectGraphNode clientProject) + { + var capabilities = clientProject.GetWebAssemblyCapabilities().ToImmutableArray(); + var targetFramework = clientProject.GetTargetFrameworkVersion() ?? throw new InvalidOperationException($"Project doesn't define {PropertyNames.TargetFrameworkMoniker}"); + + return new WebAssemblyHotReloadClient(clientLogger, agentLogger, browserRefreshServer, capabilities, targetFramework, context.EnvironmentOptions.TestFlags.HasFlag(TestFlags.MockBrowser)); + } + + private static string GetMiddlewareAssemblyPath() + => GetInjectedAssemblyPath(MiddlewareTargetFramework, "Microsoft.AspNetCore.Watch.BrowserRefresh"); + + public BrowserRefreshServer? TryCreateRefreshServer(ProjectGraphNode projectNode) + { + var logger = context.LoggerFactory.CreateLogger(BrowserRefreshServer.ServerLogComponentName, projectNode.GetDisplayName()); + + if (IsServerSupported(projectNode, logger)) + { + return new BrowserRefreshServer( + logger, + context.LoggerFactory, + middlewareAssemblyPath: GetMiddlewareAssemblyPath(), + dotnetPath: context.EnvironmentOptions.MuxerPath, + autoReloadWebSocketHostName: context.EnvironmentOptions.AutoReloadWebSocketHostName, + autoReloadWebSocketPort: context.EnvironmentOptions.AutoReloadWebSocketPort, + suppressTimeouts: context.EnvironmentOptions.TestFlags != TestFlags.None); + } + + return null; + } + + public bool IsServerSupported(ProjectGraphNode projectNode, ILogger logger) + { + if (context.EnvironmentOptions.SuppressBrowserRefresh) + { + logger.Log(MessageDescriptor.SkippingConfiguringBrowserRefresh_SuppressedViaEnvironmentVariable.WithLevelWhen(LogLevel.Error, RequiresBrowserRefresh), EnvironmentVariables.Names.SuppressBrowserRefresh); + return false; + } + + if (!projectNode.IsNetCoreApp(minVersion: s_minimumSupportedVersion)) + { + logger.Log(MessageDescriptor.SkippingConfiguringBrowserRefresh_TargetFrameworkNotSupported.WithLevelWhen(LogLevel.Error, RequiresBrowserRefresh)); + return false; + } + + logger.Log(MessageDescriptor.ConfiguredToUseBrowserRefresh); + return true; + } +} diff --git a/src/WatchPrototype/Watch/AppModels/WebServerAppModel.cs b/src/WatchPrototype/Watch/AppModels/WebServerAppModel.cs new file mode 100644 index 00000000000..d30703b8753 --- /dev/null +++ b/src/WatchPrototype/Watch/AppModels/WebServerAppModel.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 Microsoft.Build.Graph; +using Microsoft.DotNet.HotReload; +using Microsoft.Extensions.Logging; + +namespace Microsoft.DotNet.Watch; + +internal sealed class WebServerAppModel(DotNetWatchContext context, ProjectGraphNode serverProject) + : WebApplicationAppModel(context) +{ + public override ProjectGraphNode LaunchingProject => serverProject; + + public override bool RequiresBrowserRefresh + => false; + + protected override HotReloadClients CreateClients(ILogger clientLogger, ILogger agentLogger, BrowserRefreshServer? browserRefreshServer) + => new(new DefaultHotReloadClient(clientLogger, agentLogger, GetStartupHookPath(serverProject), enableStaticAssetUpdates: true), browserRefreshServer); +} diff --git a/src/WatchPrototype/Watch/Aspire/AspireServiceFactory.cs b/src/WatchPrototype/Watch/Aspire/AspireServiceFactory.cs new file mode 100644 index 00000000000..caf071157e2 --- /dev/null +++ b/src/WatchPrototype/Watch/Aspire/AspireServiceFactory.cs @@ -0,0 +1,288 @@ +// 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.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 sealed class SessionManager : IAspireServerEvents, IRuntimeProcessLauncher + { + private readonly struct Session(string dcpId, string sessionId, RunningProject runningProject, Task outputReader) + { + public string DcpId { get; } = dcpId; + public string Id { get; } = sessionId; + public RunningProject RunningProject { get; } = runningProject; + public Task OutputReader { get; } = outputReader; + } + + private static readonly UnboundedChannelOptions s_outputChannelOptions = new() + { + SingleReader = true, + SingleWriter = true + }; + + private readonly ProjectLauncher _projectLauncher; + private readonly AspireServerService _service; + private readonly ProjectOptions _hostProjectOptions; + private readonly ILogger _logger; + + /// + /// Lock to access: + /// + /// + /// + private readonly object _guard = new(); + + private readonly Dictionary _sessions = []; + private int _sessionIdDispenser; + + private volatile bool _isDisposed; + + public SessionManager(ProjectLauncher projectLauncher, ProjectOptions hostProjectOptions) + { + _projectLauncher = projectLauncher; + _hostProjectOptions = hostProjectOptions; + _logger = projectLauncher.LoggerFactory.CreateLogger(AspireLogComponentName); + + _service = new AspireServerService( + this, + displayName: ".NET Watch Aspire Server", + m => _logger.LogDebug(m)); + } + + public async ValueTask DisposeAsync() + { +#if DEBUG + lock (_guard) + { + Debug.Assert(_sessions.Count == 0); + } +#endif + _isDisposed = true; + + await _service.DisposeAsync(); + } + + public async ValueTask TerminateLaunchedProcessesAsync(CancellationToken cancellationToken) + { + ObjectDisposedException.ThrowIf(_isDisposed, this); + + ImmutableArray sessions; + lock (_guard) + { + // caller guarantees the session is active + sessions = [.. _sessions.Values]; + _sessions.Clear(); + } + + await Task.WhenAll(sessions.Select(TerminateSessionAsync)).WaitAsync(cancellationToken); + } + + public IEnumerable<(string name, string value)> GetEnvironmentVariables() + => _service.GetServerConnectionEnvironment().Select(kvp => (kvp.Key, kvp.Value)); + + /// + /// Implements https://github.com/dotnet/aspire/blob/445d2fc8a6a0b7ce3d8cc42def4d37b02709043b/docs/specs/IDE-execution.md#create-session-request. + /// + async ValueTask IAspireServerEvents.StartProjectAsync(string dcpId, ProjectLaunchRequest projectLaunchInfo, CancellationToken cancellationToken) + { + ObjectDisposedException.ThrowIf(_isDisposed, this); + + var projectOptions = GetProjectOptions(projectLaunchInfo); + var sessionId = Interlocked.Increment(ref _sessionIdDispenser).ToString(CultureInfo.InvariantCulture); + await StartProjectAsync(dcpId, sessionId, projectOptions, isRestart: false, cancellationToken); + return sessionId; + } + + public async ValueTask StartProjectAsync(string dcpId, string sessionId, ProjectOptions projectOptions, bool isRestart, CancellationToken cancellationToken) + { + ObjectDisposedException.ThrowIf(_isDisposed, this); + + _logger.LogDebug("Starting: '{Path}'", projectOptions.Representation.ProjectOrEntryPointFilePath); + + var processTerminationSource = new CancellationTokenSource(); + var outputChannel = Channel.CreateUnbounded(s_outputChannelOptions); + + RunningProject? runningProject = null; + + 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 + { + await _service.NotifySessionEndedAsync(dcpId, sessionId, processId, exitCode, cancellationToken); + } + catch (OperationCanceledException) + { + // canceled on shutdown, ignore + } + } + }, + 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}'."); + } + + await _service.NotifySessionStartedAsync(dcpId, sessionId, runningProject.ProcessId, cancellationToken); + + // cancel reading output when the process terminates: + var outputReader = StartChannelReader(runningProject.ProcessExitedCancellationToken); + + 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); + } + + _logger.LogDebug("Session started: #{SessionId}", sessionId); + return runningProject; + + async Task StartChannelReader(CancellationToken cancellationToken) + { + try + { + await foreach (var line in outputChannel.Reader.ReadAllAsync(cancellationToken)) + { + await _service.NotifyLogMessageAsync(dcpId, sessionId, isStdErr: line.IsError, data: line.Content, cancellationToken); + } + } + catch (Exception e) + { + if (!cancellationToken.IsCancellationRequested) + { + _logger.LogError("Unexpected error reading output of session '{SessionId}': {Exception}", sessionId, e); + } + } + } + } + + /// + /// Implements https://github.com/dotnet/aspire/blob/445d2fc8a6a0b7ce3d8cc42def4d37b02709043b/docs/specs/IDE-execution.md#stop-session-request. + /// + async ValueTask IAspireServerEvents.StopSessionAsync(string dcpId, string sessionId, CancellationToken cancellationToken) + { + ObjectDisposedException.ThrowIf(_isDisposed, this); + + Session session; + lock (_guard) + { + if (!_sessions.TryGetValue(sessionId, out session)) + { + return false; + } + + _sessions.Remove(sessionId); + } + + await TerminateSessionAsync(session); + return true; + } + + private async Task TerminateSessionAsync(Session session) + { + _logger.LogDebug("Stop session #{SessionId}", session.Id); + + await session.RunningProject.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() + { + IsRootProject = false, + Representation = ProjectRepresentation.FromProjectOrEntryPointFilePath(projectLaunchInfo.ProjectPath), + WorkingDirectory = Path.GetDirectoryName(projectLaunchInfo.ProjectPath) ?? throw new InvalidOperationException(), + BuildArguments = _hostProjectOptions.BuildArguments, + Command = "run", + CommandArguments = GetRunCommandArguments(projectLaunchInfo, hostLaunchProfile), + LaunchEnvironmentVariables = projectLaunchInfo.Environment?.Select(e => (e.Key, e.Value))?.ToArray() ?? [], + LaunchProfileName = projectLaunchInfo.LaunchProfile, + NoLaunchProfile = projectLaunchInfo.DisableLaunchProfile, + TargetFramework = _hostProjectOptions.TargetFramework, + }; + } + + // internal for testing + internal static IReadOnlyList GetRunCommandArguments(ProjectLaunchRequest projectLaunchInfo, string? hostLaunchProfile) + { + var arguments = new List + { + "--project", + projectLaunchInfo.ProjectPath, + }; + + // Implements https://github.com/dotnet/aspire/blob/main/docs/specs/IDE-execution.md#launch-profile-processing-project-launch-configuration + + if (projectLaunchInfo.DisableLaunchProfile) + { + arguments.Add("--no-launch-profile"); + } + else if (!string.IsNullOrEmpty(projectLaunchInfo.LaunchProfile)) + { + arguments.Add("--launch-profile"); + arguments.Add(projectLaunchInfo.LaunchProfile); + } + else if (hostLaunchProfile != null) + { + arguments.Add("--launch-profile"); + arguments.Add(hostLaunchProfile); + } + + if (projectLaunchInfo.Arguments != null) + { + if (projectLaunchInfo.Arguments.Any()) + { + arguments.AddRange(projectLaunchInfo.Arguments); + } + else + { + // indicate that no arguments should be used even if launch profile specifies some: + arguments.Add("--no-launch-profile-arguments"); + } + } + + return arguments; + } + } + + 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; +} diff --git a/src/WatchPrototype/Watch/Browser/BrowserLauncher.cs b/src/WatchPrototype/Watch/Browser/BrowserLauncher.cs new file mode 100644 index 00000000000..e789dd05828 --- /dev/null +++ b/src/WatchPrototype/Watch/Browser/BrowserLauncher.cs @@ -0,0 +1,133 @@ +// 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.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using Microsoft.Build.Graph; +using Microsoft.DotNet.HotReload; +using Microsoft.Extensions.Logging; + +namespace Microsoft.DotNet.Watch; + +internal sealed class BrowserLauncher(ILogger logger, IProcessOutputReporter processOutputReporter, EnvironmentOptions environmentOptions) +{ + // interlocked + private ImmutableHashSet _browserLaunchAttempted = []; + + /// + /// Installs browser launch/reload trigger. + /// + public void InstallBrowserLaunchTrigger( + ProcessSpec processSpec, + ProjectGraphNode projectNode, + ProjectOptions projectOptions, + AbstractBrowserRefreshServer? server, + CancellationToken cancellationToken) + { + if (!CanLaunchBrowser(projectOptions, out var launchProfile)) + { + if (environmentOptions.TestFlags.HasFlag(TestFlags.MockBrowser)) + { + logger.LogError("Test requires browser to launch"); + } + + return; + } + + WebServerProcessStateObserver.Observe(projectNode, processSpec, url => + { + if (projectOptions.IsRootProject && + ImmutableInterlocked.Update(ref _browserLaunchAttempted, static (set, key) => set.Add(key), projectNode.ProjectInstance.GetId())) + { + // first build iteration of a root project: + var launchUrl = GetLaunchUrl(launchProfile.LaunchUrl, url); + LaunchBrowser(launchUrl, server); + } + else if (server != null) + { + // Subsequent iterations (project has been rebuilt and relaunched). + // Use refresh server to reload the browser, if available. + _ = server.SendReloadMessageAsync(cancellationToken).AsTask(); + } + }); + } + + public static string GetLaunchUrl(string? profileLaunchUrl, string outputLaunchUrl) + => string.IsNullOrWhiteSpace(profileLaunchUrl) ? outputLaunchUrl : + Uri.TryCreate(profileLaunchUrl, UriKind.Absolute, out _) ? profileLaunchUrl : + Uri.TryCreate(outputLaunchUrl, UriKind.Absolute, out var launchUri) ? new Uri(launchUri, profileLaunchUrl).ToString() : + outputLaunchUrl; + + private void LaunchBrowser(string launchUrl, AbstractBrowserRefreshServer? server) + { + var (fileName, arg, useShellExecute) = environmentOptions.BrowserPath is { } browserPath + ? (browserPath, launchUrl, false) + : (launchUrl, null, true); + + logger.Log(MessageDescriptor.LaunchingBrowser, fileName, arg); + + if (environmentOptions.TestFlags != TestFlags.None && environmentOptions.BrowserPath == null) + { + if (environmentOptions.TestFlags.HasFlag(TestFlags.MockBrowser)) + { + Debug.Assert(server != null); + server.EmulateClientConnected(); + } + + return; + } + + // dotnet-watch, by default, relies on URL file association to launch browsers. On Windows and MacOS, this works fairly well + // where URLs are associated with the default browser. On Linux, this is a bit murky. + // From emperical observation, it's noted that failing to launch a browser results in either Process.Start returning a null-value + // or for the process to have immediately exited. + // We can use this to provide a helpful message. + var processSpec = new ProcessSpec() + { + Executable = fileName, + Arguments = arg != null ? [arg] : [], + UseShellExecute = useShellExecute, + OnOutput = environmentOptions.TestFlags.HasFlag(TestFlags.RedirectBrowserOutput) ? processOutputReporter.ReportOutput : null, + }; + + using var browserProcess = ProcessRunner.TryStartProcess(processSpec, logger); + if (browserProcess is null or { HasExited: true }) + { + logger.LogWarning("Unable to launch the browser. Url '{Url}'.", launchUrl); + } + } + + private bool CanLaunchBrowser(ProjectOptions projectOptions, [NotNullWhen(true)] out LaunchSettingsProfile? launchProfile) + { + launchProfile = null; + + if (environmentOptions.SuppressLaunchBrowser) + { + return false; + } + + if (!projectOptions.IsCodeExecutionCommand) + { + logger.LogDebug("Command '{Command}' does not support launching browsers.", projectOptions.Command); + return false; + } + + launchProfile = GetLaunchProfile(projectOptions); + if (launchProfile is not { LaunchBrowser: true }) + { + logger.LogDebug("launchSettings does not allow launching browsers."); + return false; + } + + logger.Log(MessageDescriptor.ConfiguredToLaunchBrowser); + return true; + } + + private LaunchSettingsProfile GetLaunchProfile(ProjectOptions projectOptions) + { + return (projectOptions.NoLaunchProfile == true + ? null : LaunchSettingsProfile.ReadLaunchProfile(projectOptions.Representation, projectOptions.LaunchProfileName, logger)) ?? new(); + } +} diff --git a/src/WatchPrototype/Watch/Browser/BrowserRefreshServerFactory.cs b/src/WatchPrototype/Watch/Browser/BrowserRefreshServerFactory.cs new file mode 100644 index 00000000000..5c6e2e9f4be --- /dev/null +++ b/src/WatchPrototype/Watch/Browser/BrowserRefreshServerFactory.cs @@ -0,0 +1,84 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.Build.Graph; +using Microsoft.DotNet.HotReload; +using Microsoft.Extensions.Logging; + +namespace Microsoft.DotNet.Watch; + +/// +/// Creates instances. +/// +/// An instance is created for each project that supports browser launching. +/// When the project is rebuilt and restarted we reuse the same refresh server and browser instance. +/// Reload message is sent to the browser in that case. +/// +/// The instances are also reused if the project file is updated or the project graph is reloaded. +/// +internal sealed class BrowserRefreshServerFactory : IDisposable +{ + private readonly Lock _serversGuard = new(); + + // Null value is cached for project instances that are not web projects or do not support browser refresh for other reason. + private readonly Dictionary _servers = []; + + public void Dispose() + { + BrowserRefreshServer?[] serversToDispose; + + lock (_serversGuard) + { + serversToDispose = [.. _servers.Values]; + _servers.Clear(); + } + + foreach (var server in serversToDispose) + { + server?.Dispose(); + }; + } + + public async ValueTask GetOrCreateBrowserRefreshServerAsync(ProjectGraphNode projectNode, WebApplicationAppModel appModel, CancellationToken cancellationToken) + { + BrowserRefreshServer? server; + bool hasExistingServer; + + var key = projectNode.ProjectInstance.GetId(); + + lock (_serversGuard) + { + hasExistingServer = _servers.TryGetValue(key, out server); + if (!hasExistingServer) + { + server = appModel.TryCreateRefreshServer(projectNode); + _servers.Add(key, server); + } + } + + if (server == null) + { + // browser refresh server isn't supported + return null; + } + + if (!hasExistingServer) + { + // Start the server we just created: + await server.StartAsync(cancellationToken); + } + + return server; + } + + public bool TryGetRefreshServer(ProjectGraphNode projectNode, [NotNullWhen(true)] out BrowserRefreshServer? server) + { + var key = projectNode.ProjectInstance.GetId(); + + lock (_serversGuard) + { + return _servers.TryGetValue(key, out server) && server != null; + } + } +} diff --git a/src/WatchPrototype/Watch/Build/BuildNames.cs b/src/WatchPrototype/Watch/Build/BuildNames.cs new file mode 100644 index 00000000000..0b2b8d0ccd0 --- /dev/null +++ b/src/WatchPrototype/Watch/Build/BuildNames.cs @@ -0,0 +1,61 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.DotNet.Watch; + +internal static class PropertyNames +{ + public const string TargetFramework = nameof(TargetFramework); + public const string TargetFrameworkIdentifier = nameof(TargetFrameworkIdentifier); + public const string TargetFrameworkMoniker = nameof(TargetFrameworkMoniker); + public const string TargetPath = nameof(TargetPath); + public const string EnableDefaultItems = nameof(EnableDefaultItems); + public const string TargetFrameworks = nameof(TargetFrameworks); + public const string WebAssemblyHotReloadCapabilities = nameof(WebAssemblyHotReloadCapabilities); + public const string TargetFrameworkVersion = nameof(TargetFrameworkVersion); + public const string TargetName = nameof(TargetName); + public const string IntermediateOutputPath = nameof(IntermediateOutputPath); + public const string HotReloadAutoRestart = nameof(HotReloadAutoRestart); + public const string DefaultItemExcludes = nameof(DefaultItemExcludes); + public const string CustomCollectWatchItems = nameof(CustomCollectWatchItems); + public const string DotNetWatchBuild = nameof(DotNetWatchBuild); + public const string DesignTimeBuild = nameof(DesignTimeBuild); + public const string SkipCompilerExecution = nameof(SkipCompilerExecution); + public const string ProvideCommandLineArgs = nameof(ProvideCommandLineArgs); +} + +internal static class ItemNames +{ + public const string Watch = nameof(Watch); + public const string AdditionalFiles = nameof(AdditionalFiles); + public const string Compile = nameof(Compile); + public const string Content = nameof(Content); + public const string ProjectCapability = nameof(ProjectCapability); + public const string ScopedCssInput = nameof(ScopedCssInput); + public const string StaticWebAssetEndpoint = nameof(StaticWebAssetEndpoint); +} + +internal static class MetadataNames +{ + public const string TargetPath = nameof(TargetPath); + public const string AssetFile = nameof(AssetFile); + public const string EndpointProperties = nameof(EndpointProperties); +} + +internal static class TargetNames +{ + public const string Compile = nameof(Compile); + public const string CompileDesignTime = nameof(CompileDesignTime); + public const string Restore = nameof(Restore); + public const string ResolveScopedCssInputs = nameof(ResolveScopedCssInputs); + public const string ResolveReferencedProjectsStaticWebAssets = nameof(ResolveReferencedProjectsStaticWebAssets); + public const string GenerateComputedBuildStaticWebAssets = nameof(GenerateComputedBuildStaticWebAssets); + public const string ReferenceCopyLocalPathsOutputGroup = nameof(ReferenceCopyLocalPathsOutputGroup); +} + +internal static class ProjectCapability +{ + public const string Aspire = nameof(Aspire); + public const string AspNetCore = nameof(AspNetCore); + public const string WebAssembly = nameof(WebAssembly); +} diff --git a/src/WatchPrototype/Watch/Build/BuildReporter.cs b/src/WatchPrototype/Watch/Build/BuildReporter.cs new file mode 100644 index 00000000000..6410476dc3c --- /dev/null +++ b/src/WatchPrototype/Watch/Build/BuildReporter.cs @@ -0,0 +1,108 @@ +// 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; +using Microsoft.Build.Framework; +using Microsoft.Build.Logging; +using Microsoft.Extensions.Logging; + +using ILogger = Microsoft.Extensions.Logging.ILogger; +using IMSBuildLogger = Microsoft.Build.Framework.ILogger; + +namespace Microsoft.DotNet.Watch; + +internal sealed class BuildReporter(ILogger logger, GlobalOptions options, EnvironmentOptions environmentOptions) +{ + public ILogger Logger => logger; + public EnvironmentOptions EnvironmentOptions => environmentOptions; + + public Loggers GetLoggers(string projectPath, string operationName) + => new(logger, environmentOptions.GetBinLogPath(projectPath, operationName, options)); + + public void ReportWatchedFiles(Dictionary fileItems) + { + logger.Log(MessageDescriptor.WatchingFilesForChanges, fileItems.Count); + + if (logger.IsEnabled(LogLevel.Trace)) + { + foreach (var file in fileItems.Values) + { + logger.Log(MessageDescriptor.WatchingFilesForChanges_FilePath, file.StaticWebAssetRelativeUrl != null + ? $"{file.FilePath}{Path.PathSeparator}{string.Join(Path.PathSeparator, file.StaticWebAssetRelativeUrl)}" + : $"{file.FilePath}"); + } + } + } + + public sealed class Loggers(ILogger logger, string? binLogPath) : IEnumerable, IDisposable + { + private readonly BinaryLogger? _binaryLogger = binLogPath != null + ? new() + { + Verbosity = LoggerVerbosity.Diagnostic, + Parameters = "LogFile=" + binLogPath, + } + : null; + + private readonly OutputLogger _outputLogger = + new(logger) + { + Verbosity = LoggerVerbosity.Minimal + }; + + public void Dispose() + { + _outputLogger.Clear(); + } + + public IEnumerator GetEnumerator() + { + yield return _outputLogger; + + if (_binaryLogger != null) + { + yield return _binaryLogger; + } + } + + public void ReportOutput() + { + if (binLogPath != null) + { + logger.LogDebug("Binary log: '{BinLogPath}'", binLogPath); + } + + _outputLogger.ReportOutput(); + } + + IEnumerator IEnumerable.GetEnumerator() + => GetEnumerator(); + } + + private sealed class OutputLogger : ConsoleLogger + { + private readonly ILogger _logger; + private readonly List _messages = []; + + public OutputLogger(ILogger logger) + { + WriteHandler = Write; + _logger = logger; + } + + public IReadOnlyList Messages + => _messages; + + public void Clear() + => _messages.Clear(); + + private void Write(string message) + => _messages.Add(new OutputLine(message.TrimEnd('\r', '\n'), IsError: false)); + + public void ReportOutput() + { + _logger.LogInformation("MSBuild output:"); + BuildOutput.ReportBuildOutput(_logger, Messages, success: false); + } + } +} diff --git a/src/WatchPrototype/Watch/Build/BuildUtilities.cs b/src/WatchPrototype/Watch/Build/BuildUtilities.cs new file mode 100644 index 00000000000..1a336530697 --- /dev/null +++ b/src/WatchPrototype/Watch/Build/BuildUtilities.cs @@ -0,0 +1,19 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.DotNet.Watch; + +internal static class BuildUtilities +{ + // Parses name=value pairs passed to --property. Skips invalid input. + public static IEnumerable<(string key, string value)> ParseBuildProperties(IEnumerable arguments) + => from argument in arguments + let colon = argument.IndexOf(':') + where colon >= 0 && argument[0..colon] is "--property" or "-property" or "/property" or "/p" or "-p" or "--p" + let eq = argument.IndexOf('=', colon) + where eq >= 0 + let name = argument[(colon + 1)..eq].Trim() + let value = argument[(eq + 1)..] + where name is not [] + select (name, value); +} diff --git a/src/WatchPrototype/Watch/Build/EvaluationResult.cs b/src/WatchPrototype/Watch/Build/EvaluationResult.cs new file mode 100644 index 00000000000..5ea9546bb2c --- /dev/null +++ b/src/WatchPrototype/Watch/Build/EvaluationResult.cs @@ -0,0 +1,240 @@ +// 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; +using Microsoft.Build.Graph; +using Microsoft.DotNet.HotReload; +using Microsoft.Extensions.Logging; + +namespace Microsoft.DotNet.Watch; + +internal sealed class EvaluationResult( + ProjectGraph projectGraph, + ImmutableArray restoredProjectInstances, + IReadOnlyDictionary files, + IReadOnlyDictionary staticWebAssetsManifests) +{ + public readonly IReadOnlyDictionary Files = files; + public readonly ProjectGraph ProjectGraph = projectGraph; + + public readonly FilePathExclusions ItemExclusions + = projectGraph != null ? FilePathExclusions.Create(projectGraph) : FilePathExclusions.Empty; + + private readonly Lazy> _lazyBuildFiles + = new(() => projectGraph != null ? CreateBuildFileSet(projectGraph) : new HashSet()); + + private static IReadOnlySet CreateBuildFileSet(ProjectGraph projectGraph) + => projectGraph.ProjectNodes.SelectMany(p => p.ProjectInstance.ImportPaths) + .Concat(projectGraph.ProjectNodes.Select(p => p.ProjectInstance.FullPath)) + .ToHashSet(PathUtilities.OSSpecificPathComparer); + + public IReadOnlySet BuildFiles + => _lazyBuildFiles.Value; + + public IReadOnlyDictionary StaticWebAssetsManifests + => staticWebAssetsManifests; + + public ImmutableArray RestoredProjectInstances + => restoredProjectInstances; + + public void WatchFiles(FileWatcher fileWatcher) + { + fileWatcher.WatchContainingDirectories(Files.Keys, includeSubdirectories: true); + + fileWatcher.WatchContainingDirectories( + StaticWebAssetsManifests.Values.SelectMany(static manifest => manifest.DiscoveryPatterns.Select(static pattern => pattern.Directory)), + includeSubdirectories: true); + + fileWatcher.WatchFiles(BuildFiles); + } + + public static ImmutableDictionary GetGlobalBuildOptions(IEnumerable buildArguments, EnvironmentOptions environmentOptions) + { + // See https://github.com/dotnet/project-system/blob/main/docs/well-known-project-properties.md + + return BuildUtilities.ParseBuildProperties(buildArguments) + .ToImmutableDictionary(keySelector: arg => arg.key, elementSelector: arg => arg.value) + .SetItem(PropertyNames.DotNetWatchBuild, "true") + .SetItem(PropertyNames.DesignTimeBuild, "true") + .SetItem(PropertyNames.SkipCompilerExecution, "true") + .SetItem(PropertyNames.ProvideCommandLineArgs, "true"); + } + + /// + /// Loads project graph and performs design-time build. + /// + public static EvaluationResult? TryCreate( + ProjectGraphFactory factory, + ILogger logger, + GlobalOptions options, + EnvironmentOptions environmentOptions, + bool restore, + CancellationToken cancellationToken) + { + var buildReporter = new BuildReporter(logger, options, environmentOptions); + + var projectGraph = factory.TryLoadProjectGraph( + logger, + projectGraphRequired: true, + cancellationToken); + + if (projectGraph == null) + { + return null; + } + + var rootNode = projectGraph.GraphRoots.Single(); + + if (restore) + { + using (var loggers = buildReporter.GetLoggers(rootNode.ProjectInstance.FullPath, "Restore")) + { + if (!rootNode.ProjectInstance.Build([TargetNames.Restore], loggers)) + { + logger.LogError("Failed to restore '{Path}'.", rootNode.ProjectInstance.FullPath); + loggers.ReportOutput(); + return null; + } + } + } + + // Capture the snapshot of original project instances after Restore target has been run. + // These instances can be used to evaluate additional targets (e.g. deployment) if needed. + var restoredProjectInstances = projectGraph.ProjectNodesTopologicallySorted.Select(node => node.ProjectInstance.DeepCopy()).ToImmutableArray(); + + var fileItems = new Dictionary(); + var staticWebAssetManifests = new Dictionary(); + + // Update the project instances of the graph with design-time build results. + // The properties and items set by DTB will be used by the Workspace to create Roslyn representation of projects. + + foreach (var project in projectGraph.ProjectNodesTopologicallySorted) + { + var projectInstance = project.ProjectInstance; + + // skip outer build project nodes: + if (projectInstance.GetPropertyValue(PropertyNames.TargetFramework) == "") + { + continue; + } + + var targets = GetBuildTargets(projectInstance, environmentOptions); + if (targets is []) + { + continue; + } + + using (var loggers = buildReporter.GetLoggers(projectInstance.FullPath, "DesignTimeBuild")) + { + if (!projectInstance.Build(targets, loggers)) + { + logger.LogError("Failed to build project '{Path}'.", projectInstance.FullPath); + loggers.ReportOutput(); + return null; + } + } + + var projectPath = projectInstance.FullPath; + var projectDirectory = Path.GetDirectoryName(projectPath)!; + + if (targets.Contains(TargetNames.GenerateComputedBuildStaticWebAssets) && + projectInstance.GetIntermediateOutputDirectory() is { } outputDir && + StaticWebAssetsManifest.TryParseFile(Path.Combine(outputDir, StaticWebAsset.ManifestFileName), logger) is { } manifest) + { + staticWebAssetManifests.Add(projectInstance.GetId(), manifest); + + // watch asset files, but not bundle files as they are regenarated when scoped CSS files are updated: + foreach (var (relativeUrl, filePath) in manifest.UrlToPathMap) + { + if (!StaticWebAsset.IsCompressedAssetFile(filePath) && !StaticWebAsset.IsScopedCssBundleFile(filePath)) + { + AddFile(filePath, staticWebAssetRelativeUrl: relativeUrl); + } + } + } + + // Adds file items for scoped css files. + // Scoped css files are bundled into a single entry per project that is represented in the static web assets manifest, + // but we need to watch the original individual files. + if (targets.Contains(TargetNames.ResolveScopedCssInputs)) + { + foreach (var item in projectInstance.GetItems(ItemNames.ScopedCssInput)) + { + AddFile(item.EvaluatedInclude, staticWebAssetRelativeUrl: null); + } + } + + // Add Watch items after other items so that we don't override properties set above. + var items = projectInstance.GetItems(ItemNames.Compile) + .Concat(projectInstance.GetItems(ItemNames.AdditionalFiles)) + .Concat(projectInstance.GetItems(ItemNames.Watch)); + + foreach (var item in items) + { + AddFile(item.EvaluatedInclude, staticWebAssetRelativeUrl: null); + } + + void AddFile(string relativePath, string? staticWebAssetRelativeUrl) + { + var filePath = Path.GetFullPath(Path.Combine(projectDirectory, relativePath)); + + if (!fileItems.TryGetValue(filePath, out var existingFile)) + { + fileItems.Add(filePath, new FileItem + { + FilePath = filePath, + ContainingProjectPaths = [projectPath], + StaticWebAssetRelativeUrl = staticWebAssetRelativeUrl, + }); + } + else if (!existingFile.ContainingProjectPaths.Contains(projectPath)) + { + // linked files might be included to multiple projects: + existingFile.ContainingProjectPaths.Add(projectPath); + } + } + } + + buildReporter.ReportWatchedFiles(fileItems); + + return new EvaluationResult(projectGraph, restoredProjectInstances, fileItems, staticWebAssetManifests); + } + + private static string[] GetBuildTargets(ProjectInstance projectInstance, EnvironmentOptions environmentOptions) + { + var compileTarget = projectInstance.Targets.ContainsKey(TargetNames.CompileDesignTime) + ? TargetNames.CompileDesignTime + : projectInstance.Targets.ContainsKey(TargetNames.Compile) + ? TargetNames.Compile + : null; + + if (compileTarget == null) + { + return []; + } + + var targets = new List + { + compileTarget + }; + + if (!environmentOptions.SuppressHandlingStaticWebAssets) + { + // generates static file asset manifest + if (projectInstance.Targets.ContainsKey(TargetNames.GenerateComputedBuildStaticWebAssets)) + { + targets.Add(TargetNames.GenerateComputedBuildStaticWebAssets); + } + + // populates ScopedCssInput items: + if (projectInstance.Targets.ContainsKey(TargetNames.ResolveScopedCssInputs)) + { + targets.Add(TargetNames.ResolveScopedCssInputs); + } + } + + targets.AddRange(projectInstance.GetStringListPropertyValue(PropertyNames.CustomCollectWatchItems)); + return [.. targets]; + } +} diff --git a/src/WatchPrototype/Watch/Build/FileItem.cs b/src/WatchPrototype/Watch/Build/FileItem.cs new file mode 100644 index 00000000000..3d3c4fd6ffb --- /dev/null +++ b/src/WatchPrototype/Watch/Build/FileItem.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. + +namespace Microsoft.DotNet.Watch +{ + internal readonly record struct FileItem + { + public required string FilePath { get; init; } + + /// + /// List of all projects that contain this file (does not contain duplicates). + /// Empty if the item is added but not been assigned to a project yet. + /// + public required List ContainingProjectPaths { get; init; } + + public string? StaticWebAssetRelativeUrl { get; init; } + } +} diff --git a/src/WatchPrototype/Watch/Build/FilePathExclusions.cs b/src/WatchPrototype/Watch/Build/FilePathExclusions.cs new file mode 100644 index 00000000000..c98def116a2 --- /dev/null +++ b/src/WatchPrototype/Watch/Build/FilePathExclusions.cs @@ -0,0 +1,104 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Build.Graph; +using Microsoft.Build.Globbing; +using Microsoft.Extensions.Logging; + +namespace Microsoft.DotNet.Watch; + +internal readonly struct FilePathExclusions( + IEnumerable<(MSBuildGlob glob, string value, string projectDir)> exclusionGlobs, + IReadOnlySet outputDirectories) +{ + public static readonly FilePathExclusions Empty = new(exclusionGlobs: [], outputDirectories: new HashSet()); + + public static FilePathExclusions Create(ProjectGraph projectGraph) + { + var outputDirectories = new HashSet(PathUtilities.OSSpecificPathComparer); + var globs = new Dictionary<(string fixedDirectoryPart, string wildcardDirectoryPart, string filenamePart), (MSBuildGlob glob, string value, string projectDir)>(); + + foreach (var projectNode in projectGraph.ProjectNodes) + { + if (projectNode.AreDefaultItemsEnabled()) + { + var projectDir = projectNode.ProjectInstance.Directory; + + foreach (var globValue in projectNode.GetDefaultItemExcludes()) + { + var glob = MSBuildGlob.Parse(projectDir, globValue); + if (glob.IsLegal) + { + // The glob creates regex based on the three parts of the glob. + // Avoid adding duplicate globs that match the same files. + globs.TryAdd((glob.FixedDirectoryPart, glob.WildcardDirectoryPart, glob.FilenamePart), (glob, globValue, projectDir)); + } + } + } + else + { + // If default items are not enabled exclude just the output directories. + + TryAddOutputDir(projectNode.ProjectInstance.GetOutputDirectory()); + TryAddOutputDir(projectNode.ProjectInstance.GetIntermediateOutputDirectory()); + + void TryAddOutputDir(string? dir) + { + try + { + if (dir != null) + { + // msbuild properties may use '\' as a directory separator even on Unix. + // GetFullPath does not normalize '\' to '/' on Unix. + if (Path.DirectorySeparatorChar == '/') + { + dir = dir.Replace('\\', '/'); + } + + outputDirectories.Add(Path.TrimEndingDirectorySeparator(Path.GetFullPath(dir))); + } + } + catch + { + // ignore + } + } + } + } + + return new FilePathExclusions(globs.Values, outputDirectories); + } + + public void Report(ILogger log) + { + foreach (var globsPerDirectory in exclusionGlobs.GroupBy(keySelector: static g => g.projectDir, elementSelector: static g => g.value)) + { + log.LogDebug("Exclusion glob: '{Globs}' under project '{Directory}'", string.Join(";", globsPerDirectory), globsPerDirectory.Key); + } + + foreach (var dir in outputDirectories) + { + log.LogDebug("Excluded directory: '{Directory}'", dir); + } + } + + internal bool IsExcluded(string fullPath, ChangeKind changeKind, ILogger logger) + { + if (PathUtilities.ContainsPath(outputDirectories, fullPath)) + { + logger.Log(MessageDescriptor.IgnoringChangeInOutputDirectory, changeKind, fullPath); + return true; + } + + foreach (var (glob, globValue, projectDir) in exclusionGlobs) + { + if (glob.IsMatch(fullPath)) + { + logger.Log(MessageDescriptor.IgnoringChangeInExcludedFile, fullPath, changeKind, "DefaultItemExcludes", globValue, projectDir); + return true; + } + } + + return false; + } +} diff --git a/src/WatchPrototype/Watch/Build/ProjectGraphFactory.cs b/src/WatchPrototype/Watch/Build/ProjectGraphFactory.cs new file mode 100644 index 00000000000..321a1c5428b --- /dev/null +++ b/src/WatchPrototype/Watch/Build/ProjectGraphFactory.cs @@ -0,0 +1,153 @@ +// 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.Reflection; +using System.Runtime.Versioning; +using Microsoft.Build.Evaluation; +using Microsoft.Build.Execution; +using Microsoft.Build.Graph; +using Microsoft.DotNet.ProjectTools; +using Microsoft.Extensions.Logging; +using ILogger = Microsoft.Extensions.Logging.ILogger; + +namespace Microsoft.DotNet.Watch; + +internal sealed class ProjectGraphFactory +{ + /// + /// Reuse with XML element caching to improve performance. + /// + /// The cache is automatically updated when build files change. + /// https://github.com/dotnet/msbuild/blob/b6f853defccd64ae1e9c7cf140e7e4de68bff07c/src/Build/Definition/ProjectCollection.cs#L343-L354 + /// + private readonly ProjectCollection _collection; + + private readonly ImmutableDictionary _globalOptions; + private readonly ProjectRepresentation _rootProject; + + // Only the root project can be virtual. #:project does not support targeting other single-file projects. + private readonly VirtualProjectBuilder? _virtualRootProjectBuilder; + + public ProjectGraphFactory( + ProjectRepresentation rootProject, + string? targetFramework, + ImmutableDictionary globalOptions) + { + _collection = new( + globalProperties: globalOptions, + loggers: [], + remoteLoggers: [], + ToolsetDefinitionLocations.Default, + maxNodeCount: 1, + onlyLogCriticalEvents: false, + loadProjectsReadOnly: false, + useAsynchronousLogging: false, + reuseProjectRootElementCache: true); + + _globalOptions = globalOptions; + _rootProject = rootProject; + + if (rootProject.EntryPointFilePath != null) + { + _virtualRootProjectBuilder = new VirtualProjectBuilder(rootProject.EntryPointFilePath, targetFramework ?? GetProductTargetFramework()); + } + } + + private static string GetProductTargetFramework() + { + var attribute = typeof(VirtualProjectBuilder).Assembly.GetCustomAttribute() ?? throw new InvalidOperationException(); + var version = new FrameworkName(attribute.FrameworkName).Version; + return $"net{version.Major}.{version.Minor}"; + } + + /// + /// Tries to create a project graph by running the build evaluation phase on the . + /// + public ProjectGraph? TryLoadProjectGraph( + ILogger logger, + bool projectGraphRequired, + CancellationToken cancellationToken) + { + var entryPoint = new ProjectGraphEntryPoint(_rootProject.ProjectGraphPath, _globalOptions); + try + { + return new ProjectGraph([entryPoint], _collection, (path, globalProperties, collection) => CreateProjectInstance(path, globalProperties, collection, logger), cancellationToken); + } + catch (ProjectCreationFailedException) + { + // Errors have already been reported. + } + catch (Exception e) when (e is not OperationCanceledException) + { + // ProjectGraph aggregates OperationCanceledException exception, + // throw here to propagate the cancellation. + cancellationToken.ThrowIfCancellationRequested(); + + logger.LogDebug("Failed to load project graph."); + + if (e is AggregateException { InnerExceptions: var innerExceptions }) + { + foreach (var inner in innerExceptions) + { + if (inner is not ProjectCreationFailedException) + { + Report(inner); + } + } + } + else + { + Report(e); + } + + void Report(Exception e) + { + if (projectGraphRequired) + { + logger.LogError(e.Message); + } + else + { + logger.LogWarning(e.Message); + } + } + } + + return null; + } + + private ProjectInstance CreateProjectInstance(string projectPath, Dictionary globalProperties, ProjectCollection projectCollection, ILogger logger) + { + if (_virtualRootProjectBuilder != null && projectPath == _rootProject.ProjectGraphPath) + { + var anyError = false; + + _virtualRootProjectBuilder.CreateProjectInstance( + projectCollection, + (sourceFile, textSpan, message) => + { + anyError = true; + logger.LogError("{Location}: {Message}", sourceFile.GetLocationString(textSpan), message); + }, + out var projectInstance, + out _); + + if (anyError) + { + throw new ProjectCreationFailedException(); + } + + return projectInstance; + } + + return new ProjectInstance( + projectPath, + globalProperties, + toolsVersion: "Current", + subToolsetVersion: null, + projectCollection); + } + + private sealed class ProjectCreationFailedException() : Exception(); +} diff --git a/src/WatchPrototype/Watch/Build/ProjectGraphUtilities.cs b/src/WatchPrototype/Watch/Build/ProjectGraphUtilities.cs new file mode 100644 index 00000000000..4adce83dc6b --- /dev/null +++ b/src/WatchPrototype/Watch/Build/ProjectGraphUtilities.cs @@ -0,0 +1,128 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Runtime.Versioning; +using Microsoft.Build.Execution; +using Microsoft.Build.Graph; + +namespace Microsoft.DotNet.Watch; + +internal static class ProjectGraphUtilities +{ + public static string GetDisplayName(this ProjectGraphNode projectNode) + => projectNode.ProjectInstance.GetDisplayName(); + + public static string GetDisplayName(this ProjectInstance project) + => $"{Path.GetFileNameWithoutExtension(project.FullPath)} ({project.GetTargetFramework()})"; + + public static string GetTargetFramework(this ProjectInstance project) + => project.GetPropertyValue(PropertyNames.TargetFramework); + + public static IEnumerable GetTargetFrameworks(this ProjectInstance project) + => project.GetStringListPropertyValue(PropertyNames.TargetFrameworks); + + public static Version? GetTargetFrameworkVersion(this ProjectGraphNode projectNode) + { + try + { + return new FrameworkName(projectNode.ProjectInstance.GetPropertyValue(PropertyNames.TargetFrameworkMoniker)).Version; + } + catch + { + return null; + } + } + + public static IEnumerable GetWebAssemblyCapabilities(this ProjectGraphNode projectNode) + => projectNode.GetStringListPropertyValue(PropertyNames.WebAssemblyHotReloadCapabilities); + + public static bool IsTargetFrameworkVersionOrNewer(this ProjectGraphNode projectNode, Version minVersion) + => projectNode.GetTargetFrameworkVersion() is { } version && version >= minVersion; + + public static bool IsNetCoreApp(string identifier) + => string.Equals(identifier, ".NETCoreApp", StringComparison.OrdinalIgnoreCase); + + public static bool IsNetCoreApp(this ProjectGraphNode projectNode) + => IsNetCoreApp(projectNode.ProjectInstance.GetPropertyValue(PropertyNames.TargetFrameworkIdentifier)); + + public static bool IsNetCoreApp(this ProjectGraphNode projectNode, Version minVersion) + => projectNode.IsNetCoreApp() && projectNode.IsTargetFrameworkVersionOrNewer(minVersion); + + public static bool IsWebApp(this ProjectGraphNode projectNode) + => projectNode.GetCapabilities().Any(static value => value is ProjectCapability.AspNetCore or ProjectCapability.WebAssembly); + + public static string? GetOutputDirectory(this ProjectInstance project) + => project.GetPropertyValue(PropertyNames.TargetPath) is { Length: >0 } path ? Path.GetDirectoryName(Path.Combine(project.Directory, path)) : null; + + public static string GetAssemblyName(this ProjectGraphNode projectNode) + => projectNode.ProjectInstance.GetPropertyValue(PropertyNames.TargetName); + + public static string? GetIntermediateOutputDirectory(this ProjectInstance project) + => project.GetPropertyValue(PropertyNames.IntermediateOutputPath) is { Length: >0 } path ? Path.Combine(project.Directory, path) : null; + + public static IEnumerable GetCapabilities(this ProjectGraphNode projectNode) + => projectNode.ProjectInstance.GetItems(ItemNames.ProjectCapability).Select(item => item.EvaluatedInclude); + + public static bool IsAutoRestartEnabled(this ProjectGraphNode projectNode) + => projectNode.GetBooleanPropertyValue(PropertyNames.HotReloadAutoRestart); + + public static bool AreDefaultItemsEnabled(this ProjectGraphNode projectNode) + => projectNode.GetBooleanPropertyValue(PropertyNames.EnableDefaultItems); + + public static IEnumerable GetDefaultItemExcludes(this ProjectGraphNode projectNode) + => projectNode.GetStringListPropertyValue(PropertyNames.DefaultItemExcludes); + + public static IEnumerable GetStringListPropertyValue(this ProjectGraphNode projectNode, string propertyName) + => projectNode.ProjectInstance.GetStringListPropertyValue(propertyName); + + public static IEnumerable GetStringListPropertyValue(this ProjectInstance project, string propertyName) + => project.GetPropertyValue(propertyName).Split(';', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + + public static bool GetBooleanPropertyValue(this ProjectGraphNode projectNode, string propertyName, bool defaultValue = false) + => GetBooleanPropertyValue(projectNode.ProjectInstance, propertyName, defaultValue); + + public static bool GetBooleanPropertyValue(this ProjectInstance project, string propertyName, bool defaultValue = false) + => project.GetPropertyValue(propertyName) is { Length: >0 } value ? bool.TryParse(value, out var result) && result : defaultValue; + + public static bool GetBooleanMetadataValue(this ProjectItemInstance item, string metadataName, bool defaultValue = false) + => item.GetMetadataValue(metadataName) is { Length: > 0 } value ? bool.TryParse(value, out var result) && result : defaultValue; + + public static IEnumerable GetAncestorsAndSelf(this ProjectGraphNode project) + => GetAncestorsAndSelf([project]); + + public static IEnumerable GetAncestorsAndSelf(this IEnumerable projects) + => GetTransitiveProjects(projects, static project => project.ReferencingProjects); + + public static IEnumerable GetDescendantsAndSelf(this ProjectGraphNode project) + => GetDescendantsAndSelf([project]); + + public static IEnumerable GetDescendantsAndSelf(this IEnumerable projects) + => GetTransitiveProjects(projects, static project => project.ProjectReferences); + + private static IEnumerable GetTransitiveProjects(IEnumerable projects, Func> getEdges) + { + var visited = new HashSet(); + var queue = new Queue(); + foreach (var project in projects) + { + queue.Enqueue(project); + } + + while (queue.Count > 0) + { + var project = queue.Dequeue(); + if (visited.Add(project)) + { + yield return project; + + foreach (var referencingProject in getEdges(project)) + { + queue.Enqueue(referencingProject); + } + } + } + } + + public static ProjectInstanceId GetId(this ProjectInstance project) + => new(project.FullPath, project.GetTargetFramework()); +} diff --git a/src/WatchPrototype/Watch/Build/ProjectInstanceId.cs b/src/WatchPrototype/Watch/Build/ProjectInstanceId.cs new file mode 100644 index 00000000000..6eefc992158 --- /dev/null +++ b/src/WatchPrototype/Watch/Build/ProjectInstanceId.cs @@ -0,0 +1,6 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.DotNet.Watch; + +internal readonly record struct ProjectInstanceId(string ProjectPath, string TargetFramework); diff --git a/src/WatchPrototype/Watch/Build/ProjectNodeMap.cs b/src/WatchPrototype/Watch/Build/ProjectNodeMap.cs new file mode 100644 index 00000000000..69e2e5a0440 --- /dev/null +++ b/src/WatchPrototype/Watch/Build/ProjectNodeMap.cs @@ -0,0 +1,73 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Build.Graph; +using Microsoft.Extensions.Logging; + +namespace Microsoft.DotNet.Watch +{ + internal readonly struct ProjectNodeMap(ProjectGraph graph, ILogger logger) + { + public readonly ProjectGraph Graph = graph; + + // full path of proj file to list of nodes representing all target frameworks of the project: + public readonly IReadOnlyDictionary> Map = + graph.ProjectNodes.GroupBy(n => n.ProjectInstance.FullPath).ToDictionary( + keySelector: static g => g.Key, + elementSelector: static g => (IReadOnlyList)[.. g]); + + public IReadOnlyList GetProjectNodes(string projectPath) + { + if (Map.TryGetValue(projectPath, out var rootProjectNodes)) + { + return rootProjectNodes; + } + + logger.LogError("Project '{ProjectPath}' not found in the project graph.", projectPath); + return []; + } + + public ProjectGraphNode? TryGetProjectNode(string projectPath, string? targetFramework) + { + var projectNodes = GetProjectNodes(projectPath); + if (projectNodes is []) + { + return null; + } + + if (targetFramework == null) + { + if (projectNodes.Count > 1) + { + logger.LogError("Project '{ProjectPath}' targets multiple frameworks. Specify which framework to run using '--framework'.", projectPath); + return null; + } + + return projectNodes[0]; + } + + ProjectGraphNode? candidate = null; + foreach (var node in projectNodes) + { + if (node.ProjectInstance.GetPropertyValue("TargetFramework") == targetFramework) + { + if (candidate != null) + { + // shouldn't be possible: + logger.LogWarning("Project '{ProjectPath}' has multiple instances targeting {TargetFramework}.", projectPath, targetFramework); + return candidate; + } + + candidate = node; + } + } + + if (candidate == null) + { + logger.LogError("Project '{ProjectPath}' doesn't have a target for {TargetFramework}.", projectPath, targetFramework); + } + + return candidate; + } + } +} diff --git a/src/WatchPrototype/Watch/Build/ProjectRepresentation.cs b/src/WatchPrototype/Watch/Build/ProjectRepresentation.cs new file mode 100644 index 00000000000..f9cb892e412 --- /dev/null +++ b/src/WatchPrototype/Watch/Build/ProjectRepresentation.cs @@ -0,0 +1,46 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.DotNet.ProjectTools; + +namespace Microsoft.DotNet.Watch; + +/// +/// Project can be reprented by project file or by entry point file (for single-file apps). +/// +internal readonly struct ProjectRepresentation(string projectGraphPath, string? projectPath, string? entryPointFilePath) +{ + /// + /// Path used in Project Graph (may be virtual). + /// + public readonly string ProjectGraphPath = projectGraphPath; + + /// + /// Path to an physical (non-virtual) project, if available. + /// + public readonly string? PhysicalPath = projectPath; + + /// + /// Path to an entry point file, if available. + /// + public readonly string? EntryPointFilePath = entryPointFilePath; + + public ProjectRepresentation(string? projectPath, string? entryPointFilePath) + : this(projectPath ?? VirtualProjectBuilder.GetVirtualProjectPath(entryPointFilePath!), projectPath, entryPointFilePath) + { + } + + public string ProjectOrEntryPointFilePath + => PhysicalPath ?? EntryPointFilePath!; + + public string GetContainingDirectory() + => Path.GetDirectoryName(ProjectOrEntryPointFilePath)!; + + public static ProjectRepresentation FromProjectOrEntryPointFilePath(string projectOrEntryPointFilePath) + => string.Equals(Path.GetExtension(projectOrEntryPointFilePath), ".csproj", StringComparison.OrdinalIgnoreCase) + ? new(projectPath: null, entryPointFilePath: projectOrEntryPointFilePath) + : new(projectPath: projectOrEntryPointFilePath, entryPointFilePath: null); + + public ProjectRepresentation WithProjectGraphPath(string projectGraphPath) + => new(projectGraphPath, PhysicalPath, EntryPointFilePath); +} diff --git a/src/WatchPrototype/Watch/Build/StaticWebAssetPattern.MSBuild.cs b/src/WatchPrototype/Watch/Build/StaticWebAssetPattern.MSBuild.cs new file mode 100644 index 00000000000..35931038b8e --- /dev/null +++ b/src/WatchPrototype/Watch/Build/StaticWebAssetPattern.MSBuild.cs @@ -0,0 +1,11 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Build.Globbing; + +namespace Microsoft.DotNet.HotReload; + +internal sealed partial class StaticWebAssetPattern +{ + public MSBuildGlob Glob => field ??= MSBuildGlob.Parse(Directory, Pattern); +} diff --git a/src/WatchPrototype/Watch/Context/DotNetWatchContext.cs b/src/WatchPrototype/Watch/Context/DotNetWatchContext.cs new file mode 100644 index 00000000000..f7caada6824 --- /dev/null +++ b/src/WatchPrototype/Watch/Context/DotNetWatchContext.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 Microsoft.Extensions.Logging; + +namespace Microsoft.DotNet.Watch +{ + internal sealed class DotNetWatchContext : IDisposable + { + public const string DefaultLogComponentName = $"{nameof(DotNetWatchContext)}:Default"; + public const string BuildLogComponentName = $"{nameof(DotNetWatchContext)}:Build"; + + public required GlobalOptions Options { get; init; } + public required EnvironmentOptions EnvironmentOptions { get; init; } + public required IProcessOutputReporter ProcessOutputReporter { get; init; } + public required ILogger Logger { get; init; } + public required ILogger BuildLogger { get; init; } + public required ILoggerFactory LoggerFactory { get; init; } + public required ProcessRunner ProcessRunner { get; init; } + + public required ProjectOptions RootProjectOptions { get; init; } + + public required BrowserRefreshServerFactory BrowserRefreshServerFactory { get; init; } + public required BrowserLauncher BrowserLauncher { get; init; } + + public void Dispose() + { + BrowserRefreshServerFactory.Dispose(); + } + } +} diff --git a/src/WatchPrototype/Watch/Context/EnvironmentOptions.cs b/src/WatchPrototype/Watch/Context/EnvironmentOptions.cs new file mode 100644 index 00000000000..1cb4f6db7bd --- /dev/null +++ b/src/WatchPrototype/Watch/Context/EnvironmentOptions.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 System.Diagnostics; +using Microsoft.Extensions.Logging; + +namespace Microsoft.DotNet.Watch +{ + [Flags] + internal enum TestFlags + { + None = 0, + RunningAsTest = 1 << 0, + MockBrowser = 1 << 1, + + /// + /// Instead of using to watch for Ctrl+C, Ctlr+R, and other keys, read from standard input. + /// This allows tests to trigger key based events. + /// + ReadKeyFromStdin = 1 << 2, + + /// + /// Redirects the output of the launched browser process to watch output. + /// + RedirectBrowserOutput = 1 << 3, + } + + internal sealed record EnvironmentOptions( + string WorkingDirectory, + string MuxerPath, + TimeSpan? ProcessCleanupTimeout, + bool IsPollingEnabled = false, + bool SuppressHandlingStaticWebAssets = false, + bool SuppressMSBuildIncrementalism = false, + bool SuppressLaunchBrowser = false, + bool SuppressBrowserRefresh = false, + bool SuppressEmojis = false, + bool RestartOnRudeEdit = false, + LogLevel? CliLogLevel = null, + string? AutoReloadWebSocketHostName = null, + int? AutoReloadWebSocketPort = null, + string? BrowserPath = null, + TestFlags TestFlags = TestFlags.None, + string TestOutput = "") + { + public static EnvironmentOptions FromEnvironment(string muxerPath) => new + ( + WorkingDirectory: Directory.GetCurrentDirectory(), + MuxerPath: ValidateMuxerPath(muxerPath), + ProcessCleanupTimeout: EnvironmentVariables.ProcessCleanupTimeout, + IsPollingEnabled: EnvironmentVariables.IsPollingEnabled, + SuppressHandlingStaticWebAssets: EnvironmentVariables.SuppressHandlingStaticWebAssets, + SuppressMSBuildIncrementalism: EnvironmentVariables.SuppressMSBuildIncrementalism, + SuppressLaunchBrowser: EnvironmentVariables.SuppressLaunchBrowser, + SuppressBrowserRefresh: EnvironmentVariables.SuppressBrowserRefresh, + SuppressEmojis: EnvironmentVariables.SuppressEmojis, + RestartOnRudeEdit: EnvironmentVariables.RestartOnRudeEdit, + CliLogLevel: EnvironmentVariables.CliLogLevel, + AutoReloadWebSocketHostName: EnvironmentVariables.AutoReloadWSHostName, + AutoReloadWebSocketPort: EnvironmentVariables.AutoReloadWSPort, + BrowserPath: EnvironmentVariables.BrowserPath, + TestFlags: EnvironmentVariables.TestFlags, + TestOutput: EnvironmentVariables.TestOutputDir + ); + + public TimeSpan GetProcessCleanupTimeout() + // Allow sufficient time for the process to exit gracefully and release resources (e.g., network ports). + => ProcessCleanupTimeout ?? TimeSpan.FromSeconds(5); + + private int _uniqueLogId; + + public bool RunningAsTest { get => (TestFlags & TestFlags.RunningAsTest) != TestFlags.None; } + + private static string ValidateMuxerPath(string path) + { + Debug.Assert(Path.GetFileNameWithoutExtension(path) == "dotnet"); + return path; + } + + public string? GetBinLogPath(string projectPath, string operationName, GlobalOptions options) + => options.BinaryLogPath != null + ? $"{Path.Combine(WorkingDirectory, options.BinaryLogPath)[..^".binlog".Length]}-dotnet-watch.{operationName}.{Path.GetFileName(projectPath)}.{Interlocked.Increment(ref _uniqueLogId)}.binlog" + : null; + } +} diff --git a/src/WatchPrototype/Watch/Context/EnvironmentVariables.cs b/src/WatchPrototype/Watch/Context/EnvironmentVariables.cs new file mode 100644 index 00000000000..6a89a52cdf8 --- /dev/null +++ b/src/WatchPrototype/Watch/Context/EnvironmentVariables.cs @@ -0,0 +1,74 @@ +// 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 EnvironmentVariables +{ + public static class Names + { + public const string DotnetWatch = "DOTNET_WATCH"; + public const string DotnetWatchIteration = "DOTNET_WATCH_ITERATION"; + + public const string DotnetLaunchProfile = "DOTNET_LAUNCH_PROFILE"; + public const string DotnetHostPath = "DOTNET_HOST_PATH"; + + public const string DotNetWatchHotReloadNamedPipeName = HotReload.AgentEnvironmentVariables.DotNetWatchHotReloadNamedPipeName; + public const string DotNetStartupHooks = HotReload.AgentEnvironmentVariables.DotNetStartupHooks; + public const string DotNetModifiableAssemblies = HotReload.AgentEnvironmentVariables.DotNetModifiableAssemblies; + public const string HotReloadDeltaClientLogMessages = HotReload.AgentEnvironmentVariables.HotReloadDeltaClientLogMessages; + + public const string SuppressBrowserRefresh = "DOTNET_WATCH_SUPPRESS_BROWSER_REFRESH"; + } + + public static LogLevel? CliLogLevel + { + get + { + var value = Environment.GetEnvironmentVariable("DOTNET_CLI_CONTEXT_VERBOSE"); + return string.Equals(value, "trace", StringComparison.OrdinalIgnoreCase) + ? LogLevel.Trace + : ParseBool(value) + ? LogLevel.Debug + : null; + } + } + + public static bool IsPollingEnabled => ReadBool("DOTNET_USE_POLLING_FILE_WATCHER"); + public static bool SuppressEmojis => ReadBool("DOTNET_WATCH_SUPPRESS_EMOJIS"); + public static bool RestartOnRudeEdit => ReadBool("DOTNET_WATCH_RESTART_ON_RUDE_EDIT"); + public static TimeSpan? ProcessCleanupTimeout => ReadTimeSpan("DOTNET_WATCH_PROCESS_CLEANUP_TIMEOUT_MS"); + + public static string SdkRootDirectory => +#if DEBUG + Environment.GetEnvironmentVariable("DOTNET_WATCH_DEBUG_SDK_DIRECTORY") ?? ""; +#else + ""; +#endif + + public static bool SuppressHandlingStaticWebAssets => ReadBool("DOTNET_WATCH_SUPPRESS_STATIC_FILE_HANDLING"); + public static bool SuppressMSBuildIncrementalism => ReadBool("DOTNET_WATCH_SUPPRESS_MSBUILD_INCREMENTALISM"); + public static bool SuppressLaunchBrowser => ReadBool("DOTNET_WATCH_SUPPRESS_LAUNCH_BROWSER"); + public static bool SuppressBrowserRefresh => ReadBool(Names.SuppressBrowserRefresh); + + public static TestFlags TestFlags => Environment.GetEnvironmentVariable("__DOTNET_WATCH_TEST_FLAGS") is { } value ? Enum.Parse(value) : TestFlags.None; + public static string TestOutputDir => Environment.GetEnvironmentVariable("__DOTNET_WATCH_TEST_OUTPUT_DIR") ?? ""; + + public static string? AutoReloadWSHostName => Environment.GetEnvironmentVariable("DOTNET_WATCH_AUTO_RELOAD_WS_HOSTNAME"); + public static int? AutoReloadWSPort => ReadInt("DOTNET_WATCH_AUTO_RELOAD_WS_PORT"); + public static string? BrowserPath => Environment.GetEnvironmentVariable("DOTNET_WATCH_BROWSER_PATH"); + + private static bool ReadBool(string variableName) + => ParseBool(Environment.GetEnvironmentVariable(variableName)); + + private static TimeSpan? ReadTimeSpan(string variableName) + => Environment.GetEnvironmentVariable(variableName) is var value && long.TryParse(value, out var intValue) && intValue >= 0 ? TimeSpan.FromMilliseconds(intValue) : null; + + private static int? ReadInt(string variableName) + => Environment.GetEnvironmentVariable(variableName) is var value && int.TryParse(value, out var intValue) ? intValue : null; + + private static bool ParseBool(string? value) + => value == "1" || bool.TryParse(value, out var boolValue) && boolValue; +} diff --git a/src/WatchPrototype/Watch/Context/GlobalOptions.cs b/src/WatchPrototype/Watch/Context/GlobalOptions.cs new file mode 100644 index 00000000000..2dc002f6070 --- /dev/null +++ b/src/WatchPrototype/Watch/Context/GlobalOptions.cs @@ -0,0 +1,19 @@ +// 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 sealed class GlobalOptions +{ + public LogLevel LogLevel { get; init; } + public bool NoHotReload { get; init; } + public bool NonInteractive { get; init; } + + /// + /// Path to binlog file (absolute or relative to working directory, includes .binlog extension), + /// or null to not generate binlog files. + /// + public string? BinaryLogPath { get; init; } +} diff --git a/src/WatchPrototype/Watch/Context/ProjectOptions.cs b/src/WatchPrototype/Watch/Context/ProjectOptions.cs new file mode 100644 index 00000000000..abede2e8ef2 --- /dev/null +++ b/src/WatchPrototype/Watch/Context/ProjectOptions.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. + +namespace Microsoft.DotNet.Watch; + +internal sealed record ProjectOptions +{ + public required bool IsRootProject { get; init; } + public required ProjectRepresentation Representation { get; init; } + public required string WorkingDirectory { get; init; } + public required string? TargetFramework { get; init; } + public required IReadOnlyList BuildArguments { get; init; } + public required bool NoLaunchProfile { get; init; } + public required string? LaunchProfileName { get; init; } + + /// + /// Command to use to launch the project. + /// + public required string Command { get; init; } + + /// + /// Arguments passed to to launch to the project. + /// + public required IReadOnlyList CommandArguments { get; init; } + + /// + /// Additional environment variables to set to the running process. + /// + public required IReadOnlyList<(string name, string value)> LaunchEnvironmentVariables { get; init; } + + /// + /// Returns true if the command executes the code of the target project. + /// + public bool IsCodeExecutionCommand + => Command is "run" or "test"; +} diff --git a/src/WatchPrototype/Watch/FileWatcher/ChangeKind.cs b/src/WatchPrototype/Watch/FileWatcher/ChangeKind.cs new file mode 100644 index 00000000000..631b13ae5d8 --- /dev/null +++ b/src/WatchPrototype/Watch/FileWatcher/ChangeKind.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. + +using Microsoft.CodeAnalysis.ExternalAccess.HotReload.Api; + +namespace Microsoft.DotNet.Watch; + +internal enum ChangeKind +{ + Update, + Add, + Delete +} + +internal static class ChangeKindExtensions +{ + public static HotReloadFileChangeKind Convert(this ChangeKind changeKind) => + changeKind switch + { + ChangeKind.Update => HotReloadFileChangeKind.Update, + ChangeKind.Add => HotReloadFileChangeKind.Add, + ChangeKind.Delete => HotReloadFileChangeKind.Delete, + _ => throw new InvalidOperationException() + }; +} + +internal readonly record struct ChangedFile(FileItem Item, ChangeKind Kind); + +internal readonly record struct ChangedPath(string Path, ChangeKind Kind); diff --git a/src/WatchPrototype/Watch/FileWatcher/DirectoryWatcher.cs b/src/WatchPrototype/Watch/FileWatcher/DirectoryWatcher.cs new file mode 100644 index 00000000000..1d7bb313782 --- /dev/null +++ b/src/WatchPrototype/Watch/FileWatcher/DirectoryWatcher.cs @@ -0,0 +1,51 @@ +// 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; + +/// +/// Watches for changes in a and its subdirectories. +/// +internal abstract class DirectoryWatcher(string watchedDirectory, ImmutableHashSet watchedFileNames, bool includeSubdirectories) : IDisposable +{ + public string WatchedDirectory { get; } = watchedDirectory; + public ImmutableHashSet WatchedFileNames { get; set; } = watchedFileNames; + public bool IncludeSubdirectories { get; } = includeSubdirectories; + + public event EventHandler? OnFileChange; + public event EventHandler? OnError; + + public abstract bool EnableRaisingEvents { get; set; } + public abstract void Dispose(); + + protected void NotifyChange(string fullPath, ChangeKind kind) + { + var onFileChange = OnFileChange; + if (onFileChange == null) + { + return; + } + + var watchedFileNames = WatchedFileNames; + if (watchedFileNames.Count > 0 && !watchedFileNames.Contains(Path.GetFileName(fullPath))) + { + return; + } + + onFileChange.Invoke(this, new ChangedPath(fullPath, kind)); + } + + protected void NotifyError(Exception e) + { + OnError?.Invoke(this, e); + } + + public static DirectoryWatcher Create(string watchedDirectory, ImmutableHashSet watchedFileNames, bool usePollingWatcher, bool includeSubdirectories) + { + return usePollingWatcher ? + new PollingDirectoryWatcher(watchedDirectory, watchedFileNames, includeSubdirectories) : + new EventBasedDirectoryWatcher(watchedDirectory, watchedFileNames, includeSubdirectories); + } +} diff --git a/src/WatchPrototype/Watch/FileWatcher/EventBasedDirectoryWatcher.cs b/src/WatchPrototype/Watch/FileWatcher/EventBasedDirectoryWatcher.cs new file mode 100644 index 00000000000..2b850405de2 --- /dev/null +++ b/src/WatchPrototype/Watch/FileWatcher/EventBasedDirectoryWatcher.cs @@ -0,0 +1,189 @@ +// 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.ComponentModel; + +namespace Microsoft.DotNet.Watch +{ + internal sealed class EventBasedDirectoryWatcher : DirectoryWatcher + { + public Action? Logger { get; set; } + + private volatile bool _disposed; + private FileSystemWatcher? _fileSystemWatcher; + private readonly Lock _createLock = new(); + + internal EventBasedDirectoryWatcher(string watchedDirectory, ImmutableHashSet watchedFileNames, bool includeSubdirectories) + : base(watchedDirectory, watchedFileNames, includeSubdirectories) + { + CreateFileSystemWatcher(); + } + + public override void Dispose() + { + _disposed = true; + DisposeInnerWatcher(); + } + + private void WatcherErrorHandler(object sender, ErrorEventArgs e) + { + if (_disposed) + { + return; + } + + Logger?.Invoke("[FW] Error"); + + var exception = e.GetException(); + + Logger?.Invoke(exception.ToString()); + + // Win32Exception may be triggered when setting EnableRaisingEvents on a file system type + // that is not supported, such as a network share. Don't attempt to recreate the watcher + // in this case as it will cause a StackOverflowException + if (exception is not Win32Exception) + { + // Recreate the watcher if it is a recoverable error. + CreateFileSystemWatcher(); + } + + NotifyError(exception); + } + + private void WatcherRenameHandler(object sender, RenamedEventArgs e) + { + if (_disposed) + { + return; + } + + Logger?.Invoke($"[FW] Renamed '{e.OldFullPath}' to '{e.FullPath}'."); + + if (Directory.Exists(e.FullPath)) + { + foreach (var newLocation in Directory.EnumerateFiles(e.FullPath, "*", SearchOption.AllDirectories)) + { + // Calculated previous path of this moved item. + var oldLocation = Path.Combine(e.OldFullPath, newLocation.Substring(e.FullPath.Length + 1)); + NotifyChange(oldLocation, ChangeKind.Delete); + NotifyChange(newLocation, ChangeKind.Add); + } + } + else + { + NotifyChange(e.OldFullPath, ChangeKind.Delete); + NotifyChange(e.FullPath, ChangeKind.Add); + } + } + + private void WatcherDeletedHandler(object sender, FileSystemEventArgs e) + { + if (_disposed) + { + return; + } + + var isDir = Directory.Exists(e.FullPath); + + Logger?.Invoke($"[FW] Deleted '{e.FullPath}'."); + + // ignore directory changes: + if (isDir) + { + return; + } + + NotifyChange(e.FullPath, ChangeKind.Delete); + } + + private void WatcherChangeHandler(object sender, FileSystemEventArgs e) + { + if (_disposed) + { + return; + } + + var isDir = Directory.Exists(e.FullPath); + + Logger?.Invoke($"[FW] Updated '{e.FullPath}'."); + + // ignore directory changes: + if (isDir) + { + return; + } + + NotifyChange(e.FullPath, ChangeKind.Update); + } + + private void WatcherAddedHandler(object sender, FileSystemEventArgs e) + { + if (_disposed) + { + return; + } + + var isDir = Directory.Exists(e.FullPath); + + Logger?.Invoke($"[FW] Added '{e.FullPath}'."); + + if (isDir) + { + return; + } + + NotifyChange(e.FullPath, ChangeKind.Add); + } + + private void CreateFileSystemWatcher() + { + lock (_createLock) + { + bool enableEvents = false; + + if (_fileSystemWatcher != null) + { + enableEvents = _fileSystemWatcher.EnableRaisingEvents; + + DisposeInnerWatcher(); + } + + _fileSystemWatcher = new FileSystemWatcher(WatchedDirectory) + { + IncludeSubdirectories = IncludeSubdirectories + }; + + _fileSystemWatcher.Created += WatcherAddedHandler; + _fileSystemWatcher.Deleted += WatcherDeletedHandler; + _fileSystemWatcher.Changed += WatcherChangeHandler; + _fileSystemWatcher.Renamed += WatcherRenameHandler; + _fileSystemWatcher.Error += WatcherErrorHandler; + + _fileSystemWatcher.EnableRaisingEvents = enableEvents; + } + } + + private void DisposeInnerWatcher() + { + if (_fileSystemWatcher != null) + { + _fileSystemWatcher.EnableRaisingEvents = false; + + _fileSystemWatcher.Created -= WatcherAddedHandler; + _fileSystemWatcher.Deleted -= WatcherDeletedHandler; + _fileSystemWatcher.Changed -= WatcherChangeHandler; + _fileSystemWatcher.Renamed -= WatcherRenameHandler; + _fileSystemWatcher.Error -= WatcherErrorHandler; + + _fileSystemWatcher.Dispose(); + } + } + + public override bool EnableRaisingEvents + { + get => _fileSystemWatcher!.EnableRaisingEvents; + set => _fileSystemWatcher!.EnableRaisingEvents = value; + } + } +} diff --git a/src/WatchPrototype/Watch/FileWatcher/FileWatcher.cs b/src/WatchPrototype/Watch/FileWatcher/FileWatcher.cs new file mode 100644 index 00000000000..1e445f5e9cb --- /dev/null +++ b/src/WatchPrototype/Watch/FileWatcher/FileWatcher.cs @@ -0,0 +1,225 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Immutable; +using System.Diagnostics; +using Microsoft.Extensions.Logging; + +namespace Microsoft.DotNet.Watch +{ + internal class FileWatcher(ILogger logger, EnvironmentOptions environmentOptions) : IDisposable + { + // Directory watcher for each watched directory tree. + // Keyed by full path to the root directory with a trailing directory separator. + protected readonly Dictionary _directoryTreeWatchers = new(PathUtilities.OSSpecificPathComparer); + + // Directory watcher for each watched directory (non-recursive). + // Keyed by full path to the root directory with a trailing directory separator. + protected readonly Dictionary _directoryWatchers = new(PathUtilities.OSSpecificPathComparer); + + private bool _disposed; + public event Action? OnFileChange; + + public bool SuppressEvents { get; set; } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + + foreach (var (_, watcher) in _directoryTreeWatchers) + { + watcher.OnFileChange -= WatcherChangedHandler; + watcher.OnError -= WatcherErrorHandler; + watcher.Dispose(); + } + } + + protected virtual DirectoryWatcher CreateDirectoryWatcher(string directory, ImmutableHashSet fileNames, bool includeSubdirectories) + { + var watcher = DirectoryWatcher.Create(directory, fileNames, environmentOptions.IsPollingEnabled, includeSubdirectories); + if (watcher is EventBasedDirectoryWatcher eventBasedWatcher) + { + eventBasedWatcher.Logger = message => logger.LogTrace(message); + } + + return watcher; + } + + public bool WatchingDirectories + => _directoryTreeWatchers.Count > 0 || _directoryWatchers.Count > 0; + + /// + /// Watches individual files. + /// + public void WatchFiles(IEnumerable filePaths) + => Watch(filePaths, containingDirectories: false, includeSubdirectories: false); + + /// + /// Watches an entire directory or directory tree. + /// + public void WatchContainingDirectories(IEnumerable filePaths, bool includeSubdirectories) + => Watch(filePaths, containingDirectories: true, includeSubdirectories); + + private void Watch(IEnumerable filePaths, bool containingDirectories, bool includeSubdirectories) + { + ObjectDisposedException.ThrowIf(_disposed, this); + Debug.Assert(containingDirectories || !includeSubdirectories); + + var filesByDirectory = + from path in filePaths + group path by PathUtilities.EnsureTrailingSlash(PathUtilities.NormalizeDirectorySeparators(Path.GetDirectoryName(path)!)) + into g + select (g.Key, containingDirectories ? [] : g.Select(Path.GetFileName).ToImmutableHashSet(PathUtilities.OSSpecificPathComparer)); + + foreach (var (directory, fileNames) in filesByDirectory) + { + // the directory is watched by active directory watcher: + if (!includeSubdirectories && _directoryWatchers.TryGetValue(directory, out var existingDirectoryWatcher)) + { + if (existingDirectoryWatcher.WatchedFileNames.IsEmpty) + { + // already watching all files in the directory + continue; + } + + if (fileNames.IsEmpty) + { + // watch all files: + existingDirectoryWatcher.WatchedFileNames = fileNames; + continue; + } + + // merge sets of watched files: + foreach (var fileName in fileNames) + { + existingDirectoryWatcher.WatchedFileNames = existingDirectoryWatcher.WatchedFileNames.Add(fileName); + } + + continue; + } + + // the directory is a root or subdirectory of active directory tree watcher: + var alreadyWatched = _directoryTreeWatchers.Any(d => directory.StartsWith(d.Key, PathUtilities.OSSpecificPathComparison)); + if (alreadyWatched) + { + continue; + } + + var newWatcher = CreateDirectoryWatcher(directory, fileNames, includeSubdirectories); + newWatcher.OnFileChange += WatcherChangedHandler; + newWatcher.OnError += WatcherErrorHandler; + newWatcher.EnableRaisingEvents = true; + + // watchers that are now redundant (covered by the new directory watcher): + if (includeSubdirectories) + { + Debug.Assert(fileNames.IsEmpty); + + RemoveRedundantWatchers(_directoryTreeWatchers); + RemoveRedundantWatchers(_directoryWatchers); + + void RemoveRedundantWatchers(Dictionary watchers) + { + var watchersToRemove = watchers + .Where(d => d.Key.StartsWith(directory, PathUtilities.OSSpecificPathComparison)) + .ToList(); + + foreach (var (watchedDirectory, watcher) in watchersToRemove) + { + watchers.Remove(watchedDirectory); + + watcher.EnableRaisingEvents = false; + watcher.OnFileChange -= WatcherChangedHandler; + watcher.OnError -= WatcherErrorHandler; + + watcher.Dispose(); + } + } + + _directoryTreeWatchers.Add(directory, newWatcher); + } + else + { + _directoryWatchers.Add(directory, newWatcher); + } + } + } + + private void WatcherErrorHandler(object? sender, Exception error) + { + if (sender is DirectoryWatcher watcher) + { + logger.LogWarning("The file watcher observing '{WatchedDirectory}' encountered an error: {Message}", watcher.WatchedDirectory, error.Message); + } + } + + private void WatcherChangedHandler(object? sender, ChangedPath change) + { + if (!SuppressEvents) + { + OnFileChange?.Invoke(change); + } + } + + public async Task WaitForFileChangeAsync(IReadOnlyDictionary fileSet, Action? startedWatching, CancellationToken cancellationToken) + { + var changedPath = await WaitForFileChangeAsync( + acceptChange: change => fileSet.ContainsKey(change.Path), + startedWatching, + cancellationToken); + + return changedPath.HasValue ? new ChangedFile(fileSet[changedPath.Value.Path], changedPath.Value.Kind) : null; + } + + public async Task WaitForFileChangeAsync(Predicate acceptChange, Action? startedWatching, CancellationToken cancellationToken) + { + var fileChangedSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + cancellationToken.Register(() => fileChangedSource.TrySetResult(null)); + + void FileChangedCallback(ChangedPath change) + { + if (acceptChange(change)) + { + fileChangedSource.TrySetResult(change); + } + } + + ChangedPath? change; + + OnFileChange += FileChangedCallback; + try + { + startedWatching?.Invoke(); + change = await fileChangedSource.Task; + } + finally + { + OnFileChange -= FileChangedCallback; + } + + return change; + } + + public static async ValueTask WaitForFileChangeAsync(string filePath, ILogger logger, EnvironmentOptions environmentOptions, Action? startedWatching, CancellationToken cancellationToken) + { + using var watcher = new FileWatcher(logger, environmentOptions); + + watcher.WatchContainingDirectories([filePath], includeSubdirectories: false); + + var fileChange = await watcher.WaitForFileChangeAsync( + acceptChange: change => change.Path == filePath, + startedWatching, + cancellationToken); + + if (fileChange != null) + { + logger.LogInformation("File changed: {FilePath}", filePath); + } + } + } +} diff --git a/src/WatchPrototype/Watch/FileWatcher/PollingDirectoryWatcher.cs b/src/WatchPrototype/Watch/FileWatcher/PollingDirectoryWatcher.cs new file mode 100644 index 00000000000..89a77475aa7 --- /dev/null +++ b/src/WatchPrototype/Watch/FileWatcher/PollingDirectoryWatcher.cs @@ -0,0 +1,189 @@ +// 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; + +namespace Microsoft.DotNet.Watch +{ + internal sealed class PollingDirectoryWatcher : DirectoryWatcher + { + // The minimum interval to rerun the scan + private static readonly TimeSpan _minRunInternal = TimeSpan.FromSeconds(.5); + + private readonly DirectoryInfo _watchedDirectory; + + private Dictionary _currentSnapshot = new(PathUtilities.OSSpecificPathComparer); + + // The following are sets that are used to calculate new snapshot and cleared on eached use (pooled): + private Dictionary _snapshotBuilder = new(PathUtilities.OSSpecificPathComparer); + private readonly Dictionary _changesBuilder = new(PathUtilities.OSSpecificPathComparer); + + private readonly Thread _pollingThread; + private bool _raiseEvents; + + private volatile bool _disposed; + + public PollingDirectoryWatcher(string watchedDirectory, ImmutableHashSet watchedFileNames, bool includeSubdirectories) + : base(watchedDirectory, watchedFileNames, includeSubdirectories) + { + _watchedDirectory = new DirectoryInfo(watchedDirectory); + + _pollingThread = new Thread(new ThreadStart(PollingLoop)) + { + IsBackground = true, + Name = nameof(PollingDirectoryWatcher) + }; + + CaptureInitialSnapshot(); + + _pollingThread.Start(); + } + + public override void Dispose() + { + EnableRaisingEvents = false; + _disposed = true; + } + + public override bool EnableRaisingEvents + { + get => _raiseEvents; + set + { + ObjectDisposedException.ThrowIf(_disposed, this); + _raiseEvents = value; + } + } + + private void PollingLoop() + { + var stopwatch = Stopwatch.StartNew(); + stopwatch.Start(); + + while (!_disposed) + { + if (stopwatch.Elapsed < _minRunInternal) + { + // Don't run too often + // The min wait time here can be double + // the value of the variable (FYI) + Thread.Sleep(_minRunInternal); + } + + stopwatch.Reset(); + + if (!_raiseEvents) + { + continue; + } + + CheckForChangedFiles(); + } + + stopwatch.Stop(); + } + + private void CaptureInitialSnapshot() + { + Debug.Assert(_currentSnapshot.Count == 0); + + ForeachEntityInDirectory(_watchedDirectory, _currentSnapshot.Add); + } + + private void CheckForChangedFiles() + { + Debug.Assert(_changesBuilder.Count == 0); + Debug.Assert(_snapshotBuilder.Count == 0); + + ForeachEntityInDirectory(_watchedDirectory, (filePath, currentWriteTime) => + { + if (!_currentSnapshot.TryGetValue(filePath, out var snapshotWriteTime)) + { + _changesBuilder.TryAdd(filePath, ChangeKind.Add); + } + else if (snapshotWriteTime != currentWriteTime) + { + _changesBuilder.TryAdd(filePath, ChangeKind.Update); + } + + _snapshotBuilder.Add(filePath, currentWriteTime); + }); + + foreach (var (filePath, _) in _currentSnapshot) + { + if (!_snapshotBuilder.ContainsKey(filePath)) + { + _changesBuilder.TryAdd(filePath, ChangeKind.Delete); + } + } + + NotifyChanges(_changesBuilder); + + // Swap the two dictionaries + (_snapshotBuilder, _currentSnapshot) = (_currentSnapshot, _snapshotBuilder); + + _changesBuilder.Clear(); + _snapshotBuilder.Clear(); + } + + private void ForeachEntityInDirectory(DirectoryInfo dirInfo, Action fileAction) + { + if (!dirInfo.Exists) + { + return; + } + + IEnumerable entities; + try + { + entities = dirInfo.EnumerateFileSystemInfos("*.*", SearchOption.TopDirectoryOnly); + } + // If the directory is deleted after the exists check this will throw and could crash the process + catch (DirectoryNotFoundException) + { + return; + } + + foreach (var entity in entities) + { + if (entity is DirectoryInfo subdirInfo) + { + if (IncludeSubdirectories) + { + ForeachEntityInDirectory(subdirInfo, fileAction); + } + } + else + { + string filePath; + DateTime currentWriteTime; + try + { + filePath = entity.FullName; + currentWriteTime = entity.LastWriteTimeUtc; + } + catch (FileNotFoundException) + { + continue; + } + + fileAction(filePath, currentWriteTime); + } + } + } + + private void NotifyChanges(Dictionary changes) + { + foreach (var (path, kind) in changes) + { + if (_disposed || !_raiseEvents) + { + break; + } + + NotifyChange(path, kind); + } + } + } +} diff --git a/src/WatchPrototype/Watch/HotReload/CompilationHandler.cs b/src/WatchPrototype/Watch/HotReload/CompilationHandler.cs new file mode 100644 index 00000000000..3a134fbc976 --- /dev/null +++ b/src/WatchPrototype/Watch/HotReload/CompilationHandler.cs @@ -0,0 +1,908 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Immutable; +using System.Diagnostics; +using Microsoft.Build.Execution; +using Microsoft.Build.Graph; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.ExternalAccess.HotReload.Api; +using Microsoft.DotNet.HotReload; +using Microsoft.Extensions.Logging; + +namespace Microsoft.DotNet.Watch +{ + internal sealed class CompilationHandler : IDisposable + { + public readonly HotReloadMSBuildWorkspace Workspace; + private readonly DotNetWatchContext _context; + private readonly HotReloadService _hotReloadService; + + /// + /// Lock to synchronize: + /// + /// + /// + private readonly object _runningProjectsAndUpdatesGuard = new(); + + /// + /// Projects that have been launched and to which we apply changes. + /// + private ImmutableDictionary> _runningProjects = ImmutableDictionary>.Empty; + + /// + /// All updates that were attempted. Includes updates whose application failed. + /// + private ImmutableList _previousUpdates = []; + + private bool _isDisposed; + private int _solutionUpdateId; + + /// + /// Current set of project instances indexed by . + /// Updated whenever the project graph changes. + /// + private ImmutableDictionary> _projectInstances = []; + + public CompilationHandler(DotNetWatchContext context) + { + _context = context; + Workspace = new HotReloadMSBuildWorkspace(context.Logger, projectFile => (instances: _projectInstances.GetValueOrDefault(projectFile, []), project: null)); + _hotReloadService = new HotReloadService(Workspace.CurrentSolution.Services, () => ValueTask.FromResult(GetAggregateCapabilities())); + } + + public void Dispose() + { + _isDisposed = true; + Workspace?.Dispose(); + } + + private ILogger Logger + => _context.Logger; + + public async ValueTask TerminateNonRootProcessesAndDispose(CancellationToken cancellationToken) + { + Logger.LogDebug("Terminating remaining child processes."); + await TerminateNonRootProcessesAsync(projectPaths: null, cancellationToken); + Dispose(); + } + + private void DiscardPreviousUpdates(ImmutableArray projectsToBeRebuilt) + { + // Remove previous updates to all modules that were affected by rude edits. + // All running projects that statically reference these modules have been terminated. + // If we missed any project that dynamically references one of these modules its rebuild will fail. + // At this point there is thus no process that these modules loaded and any process created in future + // that will load their rebuilt versions. + + lock (_runningProjectsAndUpdatesGuard) + { + _previousUpdates = _previousUpdates.RemoveAll(update => projectsToBeRebuilt.Contains(update.ProjectId)); + } + } + public async ValueTask StartSessionAsync(CancellationToken cancellationToken) + { + Logger.Log(MessageDescriptor.HotReloadSessionStarting); + + var solution = Workspace.CurrentSolution; + + await _hotReloadService.StartSessionAsync(solution, cancellationToken); + + // TODO: StartSessionAsync should do this: https://github.com/dotnet/roslyn/issues/80687 + foreach (var project in solution.Projects) + { + foreach (var document in project.AdditionalDocuments) + { + await document.GetTextAsync(cancellationToken); + } + + foreach (var document in project.AnalyzerConfigDocuments) + { + await document.GetTextAsync(cancellationToken); + } + } + + Logger.Log(MessageDescriptor.HotReloadSessionStarted); + } + + public async Task TrackRunningProjectAsync( + ProjectGraphNode projectNode, + ProjectOptions projectOptions, + HotReloadClients clients, + ProcessSpec processSpec, + RestartOperation restartOperation, + CancellationTokenSource processTerminationSource, + CancellationToken cancellationToken) + { + var processExitedSource = new CancellationTokenSource(); + + // Cancel process communication as soon as process termination is requested, shutdown is requested, or the process exits (whichever comes first). + // If we only cancel after we process exit event handler is triggered the pipe might have already been closed and may fail unexpectedly. + using var processCommunicationCancellationSource = CancellationTokenSource.CreateLinkedTokenSource(processTerminationSource.Token, processExitedSource.Token, cancellationToken); + var processCommunicationCancellationToken = processCommunicationCancellationSource.Token; + + // Dispose these objects on failure: + using var disposables = new Disposables([clients, processExitedSource]); + + // It is important to first create the named pipe connection (Hot Reload client is the named pipe server) + // and then start the process (named pipe client). Otherwise, the connection would fail. + clients.InitiateConnection(processCommunicationCancellationToken); + + RunningProject? publishedRunningProject = null; + + var previousOnExit = processSpec.OnExit; + processSpec.OnExit = async (processId, exitCode) => + { + // Await the previous action so that we only clean up after all requested "on exit" actions have been completed. + if (previousOnExit != null) + { + await previousOnExit(processId, exitCode); + } + + // Remove the running project if it has been published to _runningProjects (if it hasn't exited during initialization): + if (publishedRunningProject != null && RemoveRunningProject(publishedRunningProject)) + { + publishedRunningProject.Dispose(); + } + }; + + var launchResult = new ProcessLaunchResult(); + var runningProcess = _context.ProcessRunner.RunAsync(processSpec, clients.ClientLogger, launchResult, processTerminationSource.Token); + if (launchResult.ProcessId == null) + { + // error already reported + return null; + } + + var projectPath = projectNode.ProjectInstance.FullPath; + + try + { + // Wait for agent to create the name pipe and send capabilities over. + // the agent blocks the app execution until initial updates are applied (if any). + var capabilities = await clients.GetUpdateCapabilitiesAsync(processCommunicationCancellationToken); + + var runningProject = new RunningProject( + projectNode, + projectOptions, + clients, + runningProcess, + launchResult.ProcessId.Value, + processExitedSource: processExitedSource, + processTerminationSource: processTerminationSource, + restartOperation: restartOperation, + capabilities); + + // ownership transferred to running project: + disposables.Items.Clear(); + disposables.Items.Add(runningProject); + + var appliedUpdateCount = 0; + while (true) + { + // Observe updates that need to be applied to the new process + // and apply them before adding it to running processes. + // Do not block on udpates being made to other processes to avoid delaying the new process being up-to-date. + var updatesToApply = _previousUpdates.Skip(appliedUpdateCount).ToImmutableArray(); + if (updatesToApply.Any()) + { + await await clients.ApplyManagedCodeUpdatesAsync( + ToManagedCodeUpdates(updatesToApply), + applyOperationCancellationToken: processExitedSource.Token, + cancellationToken: processCommunicationCancellationToken); + } + + appliedUpdateCount += updatesToApply.Length; + + lock (_runningProjectsAndUpdatesGuard) + { + ObjectDisposedException.ThrowIf(_isDisposed, this); + + // More updates might have come in while we have been applying updates. + // If so, continue updating. + if (_previousUpdates.Count > appliedUpdateCount) + { + continue; + } + + // Only add the running process after it has been up-to-date. + // This will prevent new updates being applied before we have applied all the previous updates. + if (!_runningProjects.TryGetValue(projectPath, out var projectInstances)) + { + projectInstances = []; + } + + _runningProjects = _runningProjects.SetItem(projectPath, projectInstances.Add(runningProject)); + + // ownership transferred to _runningProjects + publishedRunningProject = runningProject; + disposables.Items.Clear(); + break; + } + } + + clients.OnRuntimeRudeEdit += (code, message) => + { + // fire and forget: + _ = HandleRuntimeRudeEditAsync(runningProject, message, cancellationToken); + }; + + // Notifies the agent that it can unblock the execution of the process: + await clients.InitialUpdatesAppliedAsync(processCommunicationCancellationToken); + + // If non-empty solution is loaded into the workspace (a Hot Reload session is active): + if (Workspace.CurrentSolution is { ProjectIds: not [] } currentSolution) + { + // Preparing the compilation is a perf optimization. We can skip it if the session hasn't been started yet. + PrepareCompilations(currentSolution, projectPath, cancellationToken); + } + + return runningProject; + } + catch (OperationCanceledException) when (processExitedSource.IsCancellationRequested) + { + // Process exited during initialization. This should not happen since we control the process during this time. + Logger.LogError("Failed to launch '{ProjectPath}'. Process {PID} exited during initialization.", projectPath, launchResult.ProcessId); + return null; + } + } + + private async Task HandleRuntimeRudeEditAsync(RunningProject runningProject, string rudeEditMessage, CancellationToken cancellationToken) + { + var logger = runningProject.Clients.ClientLogger; + + try + { + // Always auto-restart on runtime rude edits regardless of the settings. + // Since there is no debugger attached the process would crash on an unhandled HotReloadException if + // we let it continue executing. + logger.LogWarning(rudeEditMessage); + logger.Log(MessageDescriptor.RestartingApplication); + + if (!runningProject.InitiateRestart()) + { + // Already in the process of restarting, possibly because of another runtime rude edit. + return; + } + + await runningProject.Clients.ReportCompilationErrorsInApplicationAsync([rudeEditMessage, MessageDescriptor.RestartingApplication.GetMessage()], cancellationToken); + + // Terminate the process. + await runningProject.TerminateAsync(); + + // Creates a new running project and launches it: + await runningProject.RestartOperation(cancellationToken); + } + catch (Exception e) + { + if (e is not OperationCanceledException) + { + logger.LogError("Failed to handle runtime rude edit: {Exception}", e.ToString()); + } + } + } + + private ImmutableArray GetAggregateCapabilities() + { + var capabilities = _runningProjects + .SelectMany(p => p.Value) + .SelectMany(p => p.Capabilities) + .Distinct(StringComparer.Ordinal) + .Order() + .ToImmutableArray(); + + Logger.Log(MessageDescriptor.HotReloadCapabilities, string.Join(" ", capabilities)); + return capabilities; + } + + private static void PrepareCompilations(Solution solution, string projectPath, CancellationToken cancellationToken) + { + // Warm up the compilation. This would help make the deltas for first edit appear much more quickly + foreach (var project in solution.Projects) + { + if (project.FilePath == projectPath) + { + // fire and forget: + _ = project.GetCompilationAsync(cancellationToken); + } + } + } + + public async ValueTask<( + ImmutableArray projectUpdates, + ImmutableArray projectsToRebuild, + ImmutableArray projectsToRedeploy, + ImmutableArray projectsToRestart)> HandleManagedCodeChangesAsync( + bool autoRestart, + Func, CancellationToken, Task> restartPrompt, + CancellationToken cancellationToken) + { + var currentSolution = Workspace.CurrentSolution; + var runningProjects = _runningProjects; + + var runningProjectInfos = + (from project in currentSolution.Projects + let runningProject = GetCorrespondingRunningProject(project, runningProjects) + where runningProject != null + let autoRestartProject = autoRestart || runningProject.ProjectNode.IsAutoRestartEnabled() + select (project.Id, info: new HotReloadService.RunningProjectInfo() { RestartWhenChangesHaveNoEffect = autoRestartProject })) + .ToImmutableDictionary(e => e.Id, e => e.info); + + var updates = await _hotReloadService.GetUpdatesAsync(currentSolution, runningProjectInfos, cancellationToken); + + await DisplayResultsAsync(updates, runningProjectInfos, cancellationToken); + + if (updates.Status is HotReloadService.Status.NoChangesToApply or HotReloadService.Status.Blocked) + { + // If Hot Reload is blocked (due to compilation error) we ignore the current + // changes and await the next file change. + + // Note: CommitUpdate/DiscardUpdate is not expected to be called. + return ([], [], [], []); + } + + var projectsToPromptForRestart = + (from projectId in updates.ProjectsToRestart.Keys + where !runningProjectInfos[projectId].RestartWhenChangesHaveNoEffect // equivallent to auto-restart + select currentSolution.GetProject(projectId)!.Name).ToList(); + + if (projectsToPromptForRestart.Any() && + !await restartPrompt.Invoke(projectsToPromptForRestart, cancellationToken)) + { + _hotReloadService.DiscardUpdate(); + + Logger.Log(MessageDescriptor.HotReloadSuspended); + await Task.Delay(-1, cancellationToken); + + return ([], [], [], []); + } + + // Note: Releases locked project baseline readers, so we can rebuild any projects that need rebuilding. + _hotReloadService.CommitUpdate(); + + DiscardPreviousUpdates(updates.ProjectsToRebuild); + + var projectsToRebuild = updates.ProjectsToRebuild.Select(id => currentSolution.GetProject(id)!.FilePath!).ToImmutableArray(); + var projectsToRedeploy = updates.ProjectsToRedeploy.Select(id => currentSolution.GetProject(id)!.FilePath!).ToImmutableArray(); + + // Terminate all tracked processes that need to be restarted, + // except for the root process, which will terminate later on. + var projectsToRestart = updates.ProjectsToRestart.IsEmpty + ? [] + : await TerminateNonRootProcessesAsync(updates.ProjectsToRestart.Select(e => currentSolution.GetProject(e.Key)!.FilePath!), cancellationToken); + + return (updates.ProjectUpdates, projectsToRebuild, projectsToRedeploy, projectsToRestart); + } + + public async ValueTask ApplyUpdatesAsync(ImmutableArray updates, Stopwatch stopwatch, CancellationToken cancellationToken) + { + Debug.Assert(!updates.IsEmpty); + + ImmutableDictionary> projectsToUpdate; + lock (_runningProjectsAndUpdatesGuard) + { + // Adding the updates makes sure that all new processes receive them before they are added to running processes. + _previousUpdates = _previousUpdates.AddRange(updates); + + // Capture the set of processes that do not have the currently calculated deltas yet. + projectsToUpdate = _runningProjects; + } + + // Apply changes to all running projects, even if they do not have a static project dependency on any project that changed. + // The process may load any of the binaries using MEF or some other runtime dependency loader. + + var applyTasks = new List(); + + foreach (var (_, projects) in projectsToUpdate) + { + foreach (var runningProject in projects) + { + // Only cancel applying updates when the process exits. Canceling disables further updates since the state of the runtime becomes unknown. + var applyTask = await runningProject.Clients.ApplyManagedCodeUpdatesAsync( + ToManagedCodeUpdates(updates), + applyOperationCancellationToken: runningProject.ProcessExitedCancellationToken, + cancellationToken); + + applyTasks.Add(runningProject.CompleteApplyOperationAsync(applyTask)); + } + } + + // fire and forget: + _ = CompleteApplyOperationAsync(applyTasks, stopwatch, MessageDescriptor.ManagedCodeChangesApplied); + } + + private async Task CompleteApplyOperationAsync(IEnumerable applyTasks, Stopwatch stopwatch, MessageDescriptor message) + { + try + { + await Task.WhenAll(applyTasks); + + _context.Logger.Log(message, stopwatch.ElapsedMilliseconds); + } + catch (Exception e) + { + // Handle all exceptions since this is a fire-and-forget task. + + if (e is not OperationCanceledException) + { + _context.Logger.LogError("Failed to apply updates: {Exception}", e.ToString()); + } + } + } + + private static RunningProject? GetCorrespondingRunningProject(Project project, ImmutableDictionary> runningProjects) + { + if (project.FilePath == null || !runningProjects.TryGetValue(project.FilePath, out var projectsWithPath)) + { + return null; + } + + // msbuild workspace doesn't set TFM if the project is not multi-targeted + var tfm = HotReloadService.GetTargetFramework(project); + if (tfm == null) + { + return projectsWithPath[0]; + } + + return projectsWithPath.SingleOrDefault(p => string.Equals(p.ProjectNode.ProjectInstance.GetTargetFramework(), tfm, StringComparison.OrdinalIgnoreCase)); + } + + private async ValueTask DisplayResultsAsync(HotReloadService.Updates updates, ImmutableDictionary runningProjectInfos, CancellationToken cancellationToken) + { + switch (updates.Status) + { + case HotReloadService.Status.ReadyToApply: + break; + + case HotReloadService.Status.NoChangesToApply: + Logger.Log(MessageDescriptor.NoCSharpChangesToApply); + break; + + case HotReloadService.Status.Blocked: + Logger.Log(MessageDescriptor.UnableToApplyChanges); + break; + + default: + throw new InvalidOperationException(); + } + + if (!updates.ProjectsToRestart.IsEmpty) + { + Logger.Log(MessageDescriptor.RestartNeededToApplyChanges); + } + + var errorsToDisplayInApp = new List(); + + // Display errors first, then warnings: + ReportCompilationDiagnostics(DiagnosticSeverity.Error); + ReportCompilationDiagnostics(DiagnosticSeverity.Warning); + ReportRudeEdits(); + + // report or clear diagnostics in the browser UI + await ForEachProjectAsync( + _runningProjects, + (project, cancellationToken) => project.Clients.ReportCompilationErrorsInApplicationAsync([.. errorsToDisplayInApp], cancellationToken).AsTask() ?? Task.CompletedTask, + cancellationToken); + + void ReportCompilationDiagnostics(DiagnosticSeverity severity) + { + foreach (var diagnostic in updates.PersistentDiagnostics) + { + if (diagnostic.Id == "CS8002") + { + // TODO: This is not a useful warning. Compiler shouldn't be reporting this on .NET/ + // Referenced assembly '...' does not have a strong name" + continue; + } + + // TODO: https://github.com/dotnet/roslyn/pull/79018 + // shouldn't be included in compilation diagnostics + if (diagnostic.Id == "ENC0118") + { + // warning ENC0118: Changing 'top-level code' might not have any effect until the application is restarted + continue; + } + + if (diagnostic.DefaultSeverity != severity) + { + continue; + } + + ReportDiagnostic(diagnostic, GetMessageDescriptor(diagnostic, verbose: false)); + } + } + + void ReportRudeEdits() + { + // Rude edits in projects that caused restart of a project that can be restarted automatically + // will be reported only as verbose output. + var projectsRestartedDueToRudeEdits = updates.ProjectsToRestart + .Where(e => IsAutoRestartEnabled(e.Key)) + .SelectMany(e => e.Value) + .ToHashSet(); + + // Project with rude edit that doesn't impact running project is only listed in ProjectsToRebuild. + // Such projects are always auto-rebuilt whether or not there is any project to be restarted that needs a confirmation. + var projectsRebuiltDueToRudeEdits = updates.ProjectsToRebuild + .Where(p => !updates.ProjectsToRestart.ContainsKey(p)) + .ToHashSet(); + + foreach (var (projectId, diagnostics) in updates.TransientDiagnostics) + { + foreach (var diagnostic in diagnostics) + { + var prefix = + projectsRestartedDueToRudeEdits.Contains(projectId) ? "[auto-restart] " : + projectsRebuiltDueToRudeEdits.Contains(projectId) ? "[auto-rebuild] " : + ""; + + var descriptor = GetMessageDescriptor(diagnostic, verbose: prefix != ""); + ReportDiagnostic(diagnostic, descriptor, prefix); + } + } + } + + bool IsAutoRestartEnabled(ProjectId id) + => runningProjectInfos.TryGetValue(id, out var info) && info.RestartWhenChangesHaveNoEffect; + + void ReportDiagnostic(Diagnostic diagnostic, MessageDescriptor descriptor, string autoPrefix = "") + { + var display = CSharpDiagnosticFormatter.Instance.Format(diagnostic); + var args = new[] { autoPrefix, display }; + + Logger.Log(descriptor, args); + + if (autoPrefix != "") + { + errorsToDisplayInApp.Add(MessageDescriptor.RestartingApplicationToApplyChanges.GetMessage()); + } + else if (descriptor.Level != LogLevel.None) + { + errorsToDisplayInApp.Add(descriptor.GetMessage(args)); + } + } + + // Use the default severity of the diagnostic as it conveys impact on Hot Reload + // (ignore warnings as errors and other severity configuration). + static MessageDescriptor GetMessageDescriptor(Diagnostic diagnostic, bool verbose) + { + if (verbose) + { + return MessageDescriptor.ApplyUpdate_Verbose; + } + + if (diagnostic.Id == "ENC0118") + { + // Changing '' might not have any effect until the application is restarted. + return MessageDescriptor.ApplyUpdate_ChangingEntryPoint; + } + + return diagnostic.DefaultSeverity switch + { + DiagnosticSeverity.Error => MessageDescriptor.ApplyUpdate_Error, + DiagnosticSeverity.Warning => MessageDescriptor.ApplyUpdate_Warning, + _ => MessageDescriptor.ApplyUpdate_Verbose, + }; + } + } + + private static readonly string[] s_targets = [TargetNames.GenerateComputedBuildStaticWebAssets, TargetNames.ResolveReferencedProjectsStaticWebAssets]; + + private static bool HasScopedCssTargets(ProjectInstance projectInstance) + => s_targets.All(projectInstance.Targets.ContainsKey); + + public async ValueTask HandleStaticAssetChangesAsync( + IReadOnlyList files, + ProjectNodeMap projectMap, + IReadOnlyDictionary manifests, + Stopwatch stopwatch, + CancellationToken cancellationToken) + { + var assets = new Dictionary>(); + var projectInstancesToRegenerate = new HashSet(); + + foreach (var changedFile in files) + { + var file = changedFile.Item; + var isScopedCss = StaticWebAsset.IsScopedCssFile(file.FilePath); + + if (!isScopedCss && file.StaticWebAssetRelativeUrl is null) + { + continue; + } + + foreach (var containingProjectPath in file.ContainingProjectPaths) + { + if (!projectMap.Map.TryGetValue(containingProjectPath, out var containingProjectNodes)) + { + // Shouldn't happen. + Logger.LogWarning("Project '{Path}' not found in the project graph.", containingProjectPath); + continue; + } + + foreach (var containingProjectNode in containingProjectNodes) + { + if (isScopedCss) + { + // The outer build project instance(that specifies TargetFrameworks) won't have the target. + if (!HasScopedCssTargets(containingProjectNode.ProjectInstance)) + { + continue; + } + + projectInstancesToRegenerate.Add(containingProjectNode.ProjectInstance); + } + + foreach (var referencingProjectNode in containingProjectNode.GetAncestorsAndSelf()) + { + var applicationProjectInstance = referencingProjectNode.ProjectInstance; + if (!TryGetRunningProject(applicationProjectInstance.FullPath, out _)) + { + continue; + } + + string filePath; + string relativeUrl; + + if (isScopedCss) + { + // Razor class library may be referenced by application that does not have static assets: + if (!HasScopedCssTargets(applicationProjectInstance)) + { + continue; + } + + projectInstancesToRegenerate.Add(applicationProjectInstance); + + var bundleFileName = StaticWebAsset.GetScopedCssBundleFileName( + applicationProjectFilePath: applicationProjectInstance.FullPath, + containingProjectFilePath: containingProjectNode.ProjectInstance.FullPath); + + if (!manifests.TryGetValue(applicationProjectInstance.GetId(), out var manifest)) + { + // Shouldn't happen. + Logger.LogWarning("[{Project}] Static web asset manifest not found.", containingProjectNode.GetDisplayName()); + continue; + } + + if (!manifest.TryGetBundleFilePath(bundleFileName, out var bundleFilePath)) + { + // Shouldn't happen. + Logger.LogWarning("[{Project}] Scoped CSS bundle file '{BundleFile}' not found.", containingProjectNode.GetDisplayName(), bundleFileName); + continue; + } + + filePath = bundleFilePath; + relativeUrl = bundleFileName; + } + else + { + Debug.Assert(file.StaticWebAssetRelativeUrl != null); + filePath = file.FilePath; + relativeUrl = file.StaticWebAssetRelativeUrl; + } + + if (!assets.TryGetValue(applicationProjectInstance, out var applicationAssets)) + { + applicationAssets = []; + assets.Add(applicationProjectInstance, applicationAssets); + } + else if (applicationAssets.ContainsKey(filePath)) + { + // asset already being updated in this application project: + continue; + } + + applicationAssets.Add(filePath, new StaticWebAsset( + filePath, + StaticWebAsset.WebRoot + "/" + relativeUrl, + containingProjectNode.GetAssemblyName(), + isApplicationProject: containingProjectNode.ProjectInstance == applicationProjectInstance)); + } + } + } + } + + if (assets.Count == 0) + { + return; + } + + HashSet? failedApplicationProjectInstances = null; + if (projectInstancesToRegenerate.Count > 0) + { + var buildReporter = new BuildReporter(_context.BuildLogger, _context.Options, _context.EnvironmentOptions); + + // Note: MSBuild only allows one build at a time in a process. + foreach (var projectInstance in projectInstancesToRegenerate) + { + Logger.LogDebug("[{Project}] Regenerating scoped CSS bundle.", projectInstance.GetDisplayName()); + + using var loggers = buildReporter.GetLoggers(projectInstance.FullPath, "ScopedCss"); + + // Deep copy so that we don't pollute the project graph: + if (!projectInstance.DeepCopy().Build(s_targets, loggers)) + { + loggers.ReportOutput(); + + failedApplicationProjectInstances ??= []; + failedApplicationProjectInstances.Add(projectInstance); + } + } + } + + // Creating apply tasks involves reading static assets from disk. Parallelize this IO. + var applyTaskProducers = new List>(); + + foreach (var (applicationProjectInstance, instanceAssets) in assets) + { + if (failedApplicationProjectInstances?.Contains(applicationProjectInstance) == true) + { + continue; + } + + if (!TryGetRunningProject(applicationProjectInstance.FullPath, out var runningProjects)) + { + continue; + } + + foreach (var runningProject in runningProjects) + { + // Only cancel applying updates when the process exits. Canceling in-progress static asset update might be ok, + // but for consistency with managed code updates we only cancel when the process exits. + applyTaskProducers.Add(runningProject.Clients.ApplyStaticAssetUpdatesAsync( + instanceAssets.Values, + applyOperationCancellationToken: runningProject.ProcessExitedCancellationToken, + cancellationToken)); + } + } + + var applyTasks = await Task.WhenAll(applyTaskProducers); + + // fire and forget: + _ = CompleteApplyOperationAsync(applyTasks, stopwatch, MessageDescriptor.StaticAssetsChangesApplied); + } + + /// + /// Terminates all processes launched for non-root projects with , + /// or all running non-root project processes if is null. + /// + /// Removes corresponding entries from . + /// + /// Does not terminate the root project. + /// + /// All processes (including root) to be restarted. + internal async ValueTask> TerminateNonRootProcessesAsync( + IEnumerable? projectPaths, CancellationToken cancellationToken) + { + ImmutableArray projectsToRestart = []; + + lock (_runningProjectsAndUpdatesGuard) + { + projectsToRestart = projectPaths == null + ? [.. _runningProjects.SelectMany(entry => entry.Value)] + : [.. projectPaths.SelectMany(path => _runningProjects.TryGetValue(path, out var array) ? array : [])]; + } + + // Do not terminate root process at this time - it would signal the cancellation token we are currently using. + // The process will be restarted later on. + // Wait for all processes to exit to release their resources, so we can rebuild. + await Task.WhenAll(projectsToRestart.Where(p => !p.Options.IsRootProject).Select(p => p.TerminateForRestartAsync())).WaitAsync(cancellationToken); + + return projectsToRestart; + } + + private bool RemoveRunningProject(RunningProject project) + { + var projectPath = project.ProjectNode.ProjectInstance.FullPath; + + return UpdateRunningProjects(runningProjectsByPath => + { + if (!runningProjectsByPath.TryGetValue(projectPath, out var runningInstances)) + { + return runningProjectsByPath; + } + + var updatedRunningProjects = runningInstances.Remove(project); + return updatedRunningProjects is [] + ? runningProjectsByPath.Remove(projectPath) + : runningProjectsByPath.SetItem(projectPath, updatedRunningProjects); + }); + } + + private bool UpdateRunningProjects(Func>, ImmutableDictionary>> updater) + { + lock (_runningProjectsAndUpdatesGuard) + { + var newRunningProjects = updater(_runningProjects); + if (newRunningProjects != _runningProjects) + { + _runningProjects = newRunningProjects; + return true; + } + + return false; + } + } + + public bool TryGetRunningProject(string projectPath, out ImmutableArray projects) + { + lock (_runningProjectsAndUpdatesGuard) + { + return _runningProjects.TryGetValue(projectPath, out projects); + } + } + + private static Task ForEachProjectAsync(ImmutableDictionary> projects, Func action, CancellationToken cancellationToken) + => Task.WhenAll(projects.SelectMany(entry => entry.Value).Select(project => action(project, cancellationToken))).WaitAsync(cancellationToken); + + private static ImmutableArray ToManagedCodeUpdates(ImmutableArray updates) + => [.. updates.Select(update => new HotReloadManagedCodeUpdate(update.ModuleId, update.MetadataDelta, update.ILDelta, update.PdbDelta, update.UpdatedTypes, update.RequiredCapabilities))]; + + private static ImmutableDictionary> CreateProjectInstanceMap(ProjectGraph graph) + => graph.ProjectNodes + .GroupBy(static node => node.ProjectInstance.FullPath) + .ToImmutableDictionary( + keySelector: static group => group.Key, + elementSelector: static group => group.Select(static node => node.ProjectInstance).ToImmutableArray()); + + public async Task UpdateProjectConeAsync(ProjectGraph projectGraph, ProjectRepresentation project, CancellationToken cancellationToken) + { + Logger.LogInformation("Loading projects ..."); + var stopwatch = Stopwatch.StartNew(); + + _projectInstances = CreateProjectInstanceMap(projectGraph); + + var solution = await Workspace.UpdateProjectConeAsync(project.ProjectGraphPath, cancellationToken); + await SolutionUpdatedAsync(solution, "project update", cancellationToken); + + Logger.LogInformation("Projects loaded in {Time}s.", stopwatch.Elapsed.TotalSeconds.ToString("0.0")); + } + + public async Task UpdateFileContentAsync(IReadOnlyList changedFiles, CancellationToken cancellationToken) + { + var solution = await Workspace.UpdateFileContentAsync(changedFiles.Select(static f => (f.Item.FilePath, f.Kind.Convert())), cancellationToken); + await SolutionUpdatedAsync(solution, "document update", cancellationToken); + } + + private Task SolutionUpdatedAsync(Solution newSolution, string operationDisplayName, CancellationToken cancellationToken) + => ReportSolutionFilesAsync(newSolution, Interlocked.Increment(ref _solutionUpdateId), operationDisplayName, cancellationToken); + + private async Task ReportSolutionFilesAsync(Solution solution, int updateId, string operationDisplayName, CancellationToken cancellationToken) + { + Logger.LogDebug("Solution after {Operation}: v{Version}", operationDisplayName, updateId); + + if (!Logger.IsEnabled(LogLevel.Trace)) + { + return; + } + + foreach (var project in solution.Projects) + { + Logger.LogDebug(" Project: {Path}", project.FilePath); + + foreach (var document in project.Documents) + { + await InspectDocumentAsync(document, "Document").ConfigureAwait(false); + } + + foreach (var document in project.AdditionalDocuments) + { + await InspectDocumentAsync(document, "Additional").ConfigureAwait(false); + } + + foreach (var document in project.AnalyzerConfigDocuments) + { + await InspectDocumentAsync(document, "Config").ConfigureAwait(false); + } + } + + async ValueTask InspectDocumentAsync(TextDocument document, string kind) + { + var text = await document.GetTextAsync(cancellationToken).ConfigureAwait(false); + Logger.LogDebug(" {Kind}: {FilePath} [{Checksum}]", kind, document.FilePath, Convert.ToBase64String(text.GetChecksum().ToArray())); + } + } + } +} diff --git a/src/WatchPrototype/Watch/HotReload/HotReloadDotNetWatcher.cs b/src/WatchPrototype/Watch/HotReload/HotReloadDotNetWatcher.cs new file mode 100644 index 00000000000..c0ce376c665 --- /dev/null +++ b/src/WatchPrototype/Watch/HotReload/HotReloadDotNetWatcher.cs @@ -0,0 +1,1008 @@ +// 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.Text.Encodings.Web; +using Microsoft.Build.Execution; +using Microsoft.Build.Graph; +using Microsoft.CodeAnalysis; +using Microsoft.DotNet.HotReload; +using Microsoft.Extensions.Logging; + +namespace Microsoft.DotNet.Watch +{ + internal sealed class HotReloadDotNetWatcher + { + public const string ClientLogComponentName = $"{nameof(HotReloadDotNetWatcher)}:Client"; + public const string AgentLogComponentName = $"{nameof(HotReloadDotNetWatcher)}:Agent"; + + private readonly IConsole _console; + private readonly IRuntimeProcessLauncherFactory? _runtimeProcessLauncherFactory; + private readonly RestartPrompt? _rudeEditRestartPrompt; + + private readonly DotNetWatchContext _context; + private readonly ProjectGraphFactory _designTimeBuildGraphFactory; + + internal Task? Test_FileChangesCompletedTask { get; set; } + + public HotReloadDotNetWatcher(DotNetWatchContext context, IConsole console, IRuntimeProcessLauncherFactory? runtimeProcessLauncherFactory) + { + _context = context; + _console = console; + _runtimeProcessLauncherFactory = runtimeProcessLauncherFactory; + if (!context.Options.NonInteractive) + { + var consoleInput = new ConsoleInputReader(_console, context.Options.LogLevel, context.EnvironmentOptions.SuppressEmojis); + + var noPrompt = context.EnvironmentOptions.RestartOnRudeEdit; + if (noPrompt) + { + context.Logger.LogDebug("DOTNET_WATCH_RESTART_ON_RUDE_EDIT = 'true'. Will restart without prompt."); + } + + _rudeEditRestartPrompt = new RestartPrompt(context.Logger, consoleInput, noPrompt ? true : null); + } + + _designTimeBuildGraphFactory = new ProjectGraphFactory( + _context.RootProjectOptions.Representation, + _context.RootProjectOptions.TargetFramework, + globalOptions: EvaluationResult.GetGlobalBuildOptions( + context.RootProjectOptions.BuildArguments, + context.EnvironmentOptions)); + } + + public async Task WatchAsync(CancellationToken shutdownCancellationToken) + { + CancellationTokenSource? forceRestartCancellationSource = null; + + _context.Logger.Log(MessageDescriptor.HotReloadEnabled); + _context.Logger.Log(MessageDescriptor.PressCtrlRToRestart); + + _console.KeyPressed += (key) => + { + if (key.Modifiers.HasFlag(ConsoleModifiers.Control) && key.Key == ConsoleKey.R && forceRestartCancellationSource is { } source) + { + // provide immediate feedback to the user: + _context.Logger.Log(source.IsCancellationRequested ? MessageDescriptor.RestartInProgress : MessageDescriptor.RestartRequested); + source.Cancel(); + } + }; + + using var fileWatcher = new FileWatcher(_context.Logger, _context.EnvironmentOptions); + + for (var iteration = 0; !shutdownCancellationToken.IsCancellationRequested; iteration++) + { + Interlocked.Exchange(ref forceRestartCancellationSource, new CancellationTokenSource())?.Dispose(); + + using var rootProcessTerminationSource = new CancellationTokenSource(); + + // This source will signal when the user cancels (either Ctrl+R or Ctrl+C) or when the root process terminates: + using var iterationCancellationSource = CancellationTokenSource.CreateLinkedTokenSource(shutdownCancellationToken, forceRestartCancellationSource.Token, rootProcessTerminationSource.Token); + var iterationCancellationToken = iterationCancellationSource.Token; + + var waitForFileChangeBeforeRestarting = true; + EvaluationResult? evaluationResult = null; + RunningProject? rootRunningProject = null; + IRuntimeProcessLauncher? runtimeProcessLauncher = null; + CompilationHandler? compilationHandler = null; + Action? fileChangedCallback = null; + + try + { + var rootProjectOptions = _context.RootProjectOptions; + + var buildSucceeded = await BuildProjectAsync(rootProjectOptions.Representation, rootProjectOptions.BuildArguments, iterationCancellationToken); + if (!buildSucceeded) + { + continue; + } + + // Evaluate the target to find out the set of files to watch. + // In case the app fails to start due to build or other error we can wait for these files to change. + // Avoid restore since the build above already restored the root project. + evaluationResult = await EvaluateRootProjectAsync(restore: false, iterationCancellationToken); + + var rootProject = evaluationResult.ProjectGraph.GraphRoots.Single(); + + // use normalized MSBuild path so that we can index into the ProjectGraph + rootProjectOptions = rootProjectOptions with + { + Representation = rootProjectOptions.Representation.WithProjectGraphPath(rootProject.ProjectInstance.FullPath) + }; + + var runtimeProcessLauncherFactory = _runtimeProcessLauncherFactory; + var rootProjectCapabilities = rootProject.GetCapabilities(); + if (rootProjectCapabilities.Contains(AspireServiceFactory.AppHostProjectCapability)) + { + runtimeProcessLauncherFactory ??= AspireServiceFactory.Instance; + _context.Logger.LogDebug("Using Aspire process launcher."); + } + + var projectMap = new ProjectNodeMap(evaluationResult.ProjectGraph, _context.Logger); + compilationHandler = new CompilationHandler(_context); + var projectLauncher = new ProjectLauncher(_context, projectMap, compilationHandler, iteration); + evaluationResult.ItemExclusions.Report(_context.Logger); + + runtimeProcessLauncher = runtimeProcessLauncherFactory?.TryCreate(rootProject, projectLauncher, rootProjectOptions); + if (runtimeProcessLauncher != null) + { + var launcherEnvironment = runtimeProcessLauncher.GetEnvironmentVariables(); + rootProjectOptions = rootProjectOptions with + { + LaunchEnvironmentVariables = [.. rootProjectOptions.LaunchEnvironmentVariables, .. launcherEnvironment] + }; + } + + rootRunningProject = await projectLauncher.TryLaunchProcessAsync( + rootProjectOptions, + rootProcessTerminationSource, + onOutput: null, + onExit: null, + restartOperation: new RestartOperation(_ => default), // the process will automatically restart + iterationCancellationToken); + + if (rootRunningProject == null) + { + // error has been reported: + waitForFileChangeBeforeRestarting = false; + return; + } + + // Cancel iteration as soon as the root process exits, so that we don't spent time loading solution, etc. when the process is already dead. + rootRunningProject.ProcessExitedCancellationToken.Register(iterationCancellationSource.Cancel); + + if (shutdownCancellationToken.IsCancellationRequested) + { + // Ctrl+C: + return; + } + + if (!await rootRunningProject.WaitForProcessRunningAsync(iterationCancellationToken)) + { + // Process might have exited while we were trying to communicate with it. + // Cancel the iteration, but wait for a file change before starting a new one. + iterationCancellationSource.Cancel(); + iterationCancellationSource.Token.ThrowIfCancellationRequested(); + } + + if (shutdownCancellationToken.IsCancellationRequested) + { + // Ctrl+C: + return; + } + + await compilationHandler.UpdateProjectConeAsync(evaluationResult.ProjectGraph, rootProjectOptions.Representation, iterationCancellationToken); + + // Solution must be initialized after we load the solution but before we start watching for file changes to avoid race condition + // when the EnC session captures content of the file after the changes has already been made. + // The session must also start after the project is built, so that the EnC service can read document checksums from the PDB. + await compilationHandler.StartSessionAsync(iterationCancellationToken); + + if (shutdownCancellationToken.IsCancellationRequested) + { + // Ctrl+C: + return; + } + + evaluationResult.WatchFiles(fileWatcher); + + var changedFilesAccumulator = ImmutableList.Empty; + + void FileChangedCallback(ChangedPath change) + { + if (AcceptChange(change, evaluationResult)) + { + _context.Logger.LogDebug("File change: {Kind} '{Path}'.", change.Kind, change.Path); + ImmutableInterlocked.Update(ref changedFilesAccumulator, changedPaths => changedPaths.Add(change)); + } + } + + fileChangedCallback = FileChangedCallback; + fileWatcher.OnFileChange += fileChangedCallback; + _context.Logger.Log(MessageDescriptor.WaitingForChanges); + + // Hot Reload loop - exits when the root process needs to be restarted. + bool extendTimeout = false; + while (true) + { + try + { + if (Test_FileChangesCompletedTask != null) + { + await Test_FileChangesCompletedTask; + } + + // Use timeout to batch file changes. If the process doesn't exit within the given timespan we'll check + // for accumulated file changes. If there are any we attempt Hot Reload. Otherwise we come back here to wait again. + _ = await rootRunningProject.RunningProcess.WaitAsync(TimeSpan.FromMilliseconds(extendTimeout ? 200 : 50), iterationCancellationToken); + + // Process exited: cancel the iteration, but wait for a file change before starting a new one + waitForFileChangeBeforeRestarting = true; + iterationCancellationSource.Cancel(); + break; + } + catch (TimeoutException) + { + // check for changed files + } + catch (OperationCanceledException) + { + // Ctrl+C, forced restart, or process exited. + Debug.Assert(iterationCancellationToken.IsCancellationRequested); + + // Will wait for a file change if process exited. + waitForFileChangeBeforeRestarting = true; + break; + } + + // If the changes include addition/deletion wait a little bit more for possible matching deletion/addition. + // This eliminates reevaluations caused by teared add + delete of a temp file or a move of a file. + if (!extendTimeout && changedFilesAccumulator.Any(change => change.Kind is ChangeKind.Add or ChangeKind.Delete)) + { + extendTimeout = true; + continue; + } + + extendTimeout = false; + + var changedFiles = await CaptureChangedFilesSnapshot(rebuiltProjects: []); + if (changedFiles is []) + { + continue; + } + + if (!rootProjectCapabilities.Contains("SupportsHotReload")) + { + _context.Logger.LogWarning("Project '{Name}' does not support Hot Reload and must be rebuilt.", rootProject.GetDisplayName()); + + // file change already detected + waitForFileChangeBeforeRestarting = false; + iterationCancellationSource.Cancel(); + break; + } + + HotReloadEventSource.Log.HotReloadStart(HotReloadEventSource.StartType.Main); + var stopwatch = Stopwatch.StartNew(); + + HotReloadEventSource.Log.HotReloadStart(HotReloadEventSource.StartType.StaticHandler); + await compilationHandler.HandleStaticAssetChangesAsync(changedFiles, projectMap, evaluationResult.StaticWebAssetsManifests, stopwatch, iterationCancellationToken); + HotReloadEventSource.Log.HotReloadEnd(HotReloadEventSource.StartType.StaticHandler); + + HotReloadEventSource.Log.HotReloadStart(HotReloadEventSource.StartType.CompilationHandler); + + var (managedCodeUpdates, projectsToRebuild, projectsToRedeploy, projectsToRestart) = await compilationHandler.HandleManagedCodeChangesAsync( + autoRestart: _context.Options.NonInteractive || _rudeEditRestartPrompt?.AutoRestartPreference is true, + restartPrompt: async (projectNames, cancellationToken) => + { + if (_rudeEditRestartPrompt != null) + { + // stop before waiting for user input: + stopwatch.Stop(); + + string question; + if (runtimeProcessLauncher == null) + { + question = "Do you want to restart your app?"; + } + else + { + _context.Logger.LogInformation("Affected projects:"); + + foreach (var projectName in projectNames.OrderBy(n => n)) + { + _context.Logger.LogInformation(" {ProjectName}", projectName); + } + + question = "Do you want to restart these projects?"; + } + + return await _rudeEditRestartPrompt.WaitForRestartConfirmationAsync(question, cancellationToken); + } + + _context.Logger.LogDebug("Restarting without prompt since dotnet-watch is running in non-interactive mode."); + + foreach (var projectName in projectNames) + { + _context.Logger.LogDebug(" Project to restart: '{ProjectName}'", projectName); + } + + return true; + }, + iterationCancellationToken); + + HotReloadEventSource.Log.HotReloadEnd(HotReloadEventSource.StartType.CompilationHandler); + + stopwatch.Stop(); + + HotReloadEventSource.Log.HotReloadEnd(HotReloadEventSource.StartType.Main); + + // Terminate root process if it had rude edits or is non-reloadable. + if (projectsToRestart.SingleOrDefault(project => project.Options.IsRootProject) is { } rootProjectToRestart) + { + // Triggers rootRestartCancellationToken. + waitForFileChangeBeforeRestarting = false; + break; + } + + if (!projectsToRebuild.IsEmpty) + { + while (true) + { + iterationCancellationToken.ThrowIfCancellationRequested(); + + // pause accumulating file changes during build: + fileWatcher.SuppressEvents = true; + try + { + // Build projects sequentially to avoid failed attempts to overwrite dependent project outputs. + // TODO: Ideally, dotnet build would be able to build multiple projects. https://github.com/dotnet/sdk/issues/51311 + var success = true; + foreach (var projectPath in projectsToRebuild) + { + // The path of the Workspace Project is the entry-point file path for single-file apps. + success = await BuildProjectAsync(ProjectRepresentation.FromProjectOrEntryPointFilePath(projectPath), rootProjectOptions.BuildArguments, iterationCancellationToken); + if (!success) + { + break; + } + } + + if (success) + { + break; + } + } + finally + { + fileWatcher.SuppressEvents = false; + } + + iterationCancellationToken.ThrowIfCancellationRequested(); + + _ = await fileWatcher.WaitForFileChangeAsync( + change => AcceptChange(change, evaluationResult), + startedWatching: () => _context.Logger.Log(MessageDescriptor.FixBuildError), + shutdownCancellationToken); + } + + // Changes made since last snapshot of the accumulator shouldn't be included in next Hot Reload update. + // Apply them to the workspace. + _ = await CaptureChangedFilesSnapshot(projectsToRebuild); + + _context.Logger.Log(MessageDescriptor.ProjectsRebuilt, projectsToRebuild.Length); + } + + // Deploy dependencies after rebuilding and before restarting. + if (!projectsToRedeploy.IsEmpty) + { + DeployProjectDependencies(evaluationResult.RestoredProjectInstances, projectsToRedeploy, iterationCancellationToken); + _context.Logger.Log(MessageDescriptor.ProjectDependenciesDeployed, projectsToRedeploy.Length); + } + + // Apply updates only after dependencies have been deployed, + // so that updated code doesn't attempt to access the dependency before it has been deployed. + if (!managedCodeUpdates.IsEmpty) + { + await compilationHandler.ApplyUpdatesAsync(managedCodeUpdates, stopwatch, iterationCancellationToken); + } + + if (!projectsToRestart.IsEmpty) + { + await Task.WhenAll( + projectsToRestart.Select(async runningProject => + { + var newRunningProject = await runningProject.RestartOperation(shutdownCancellationToken); + _ = await newRunningProject.WaitForProcessRunningAsync(shutdownCancellationToken); + })) + .WaitAsync(shutdownCancellationToken); + + _context.Logger.Log(MessageDescriptor.ProjectsRestarted, projectsToRestart.Length); + } + + async Task> CaptureChangedFilesSnapshot(ImmutableArray rebuiltProjects) + { + var changedPaths = Interlocked.Exchange(ref changedFilesAccumulator, []); + if (changedPaths is []) + { + return []; + } + + // Note: + // It is possible that we could have received multiple changes for a file that should cancel each other (such as Delete + Add), + // but they end up split into two snapshots and we will interpret them as two separate Delete and Add changes that trigger + // two sets of Hot Reload updates. Hence the normalization is best effort as we can't predict future. + + var changedFiles = NormalizePathChanges(changedPaths) + .Select(changedPath => + { + // On macOS may report Update followed by Add when a new file is created or just updated. + // We normalize Update + Add to just Add and Update + Add + Delete to Update above. + // To distinguish between an addition and an update we check if the file exists. + + if (evaluationResult.Files.TryGetValue(changedPath.Path, out var existingFileItem)) + { + var changeKind = changedPath.Kind == ChangeKind.Add ? ChangeKind.Update : changedPath.Kind; + + return new ChangedFile(existingFileItem, changeKind); + } + + // Do not assume the change is an addition, even if the file doesn't exist in the evaluation result. + // The file could have been deleted and Add + Delete sequence could have been normalized to Update. + return new ChangedFile( + new FileItem() { FilePath = changedPath.Path, ContainingProjectPaths = [] }, + changedPath.Kind); + }) + .ToList(); + + ReportFileChanges(changedFiles); + + AnalyzeFileChanges(changedFiles, evaluationResult, out var evaluationRequired); + + if (evaluationRequired) + { + // TODO: consider re-evaluating only affected projects instead of the whole graph. + evaluationResult = await EvaluateRootProjectAsync(restore: true, iterationCancellationToken); + + // additional files/directories may have been added: + evaluationResult.WatchFiles(fileWatcher); + + await compilationHandler.UpdateProjectConeAsync(evaluationResult.ProjectGraph, rootProjectOptions.Representation, iterationCancellationToken); + + if (shutdownCancellationToken.IsCancellationRequested) + { + // Ctrl+C: + return []; + } + + // Update files in the change set with new evaluation info. + for (var i = 0; i < changedFiles.Count; i++) + { + var file = changedFiles[i]; + if (evaluationResult.Files.TryGetValue(file.Item.FilePath, out var evaluatedFile)) + { + changedFiles[i] = file with { Item = evaluatedFile }; + } + } + + _context.Logger.Log(MessageDescriptor.ReEvaluationCompleted); + } + + if (!rebuiltProjects.IsEmpty) + { + // Filter changed files down to those contained in projects being rebuilt. + // File changes that affect projects that are not being rebuilt will stay in the accumulator + // and be included in the next Hot Reload change set. + var rebuiltProjectPaths = rebuiltProjects.ToHashSet(); + + var newAccumulator = ImmutableList.Empty; + var newChangedFiles = new List(); + + foreach (var file in changedFiles) + { + if (file.Item.ContainingProjectPaths.All(rebuiltProjectPaths.Contains)) + { + newChangedFiles.Add(file); + } + else + { + newAccumulator = newAccumulator.Add(new ChangedPath(file.Item.FilePath, file.Kind)); + } + } + + changedFiles = newChangedFiles; + + ImmutableInterlocked.Update(ref changedFilesAccumulator, accumulator => accumulator.AddRange(newAccumulator)); + } + + if (!evaluationRequired) + { + // Update the workspace to reflect changes in the file content:. + // If the project was re-evaluated the Roslyn solution is already up to date. + await compilationHandler.UpdateFileContentAsync(changedFiles, iterationCancellationToken); + } + + return [.. changedFiles]; + } + } + } + catch (OperationCanceledException) when (!shutdownCancellationToken.IsCancellationRequested) + { + // start next iteration unless shutdown is requested + } + catch (Exception) when ((waitForFileChangeBeforeRestarting = false) == true) + { + // unreachable + throw new InvalidOperationException(); + } + finally + { + // stop watching file changes: + if (fileChangedCallback != null) + { + fileWatcher.OnFileChange -= fileChangedCallback; + } + + if (runtimeProcessLauncher != null) + { + // Request cleanup of all processes created by the launcher before we terminate the root process. + // Non-cancellable - can only be aborted by forced Ctrl+C, which immediately kills the dotnet-watch process. + await runtimeProcessLauncher.TerminateLaunchedProcessesAsync(CancellationToken.None); + } + + if (compilationHandler != null) + { + // Non-cancellable - can only be aborted by forced Ctrl+C, which immediately kills the dotnet-watch process. + await compilationHandler.TerminateNonRootProcessesAndDispose(CancellationToken.None); + } + + if (rootRunningProject != null) + { + await rootRunningProject.TerminateAsync(); + } + + if (runtimeProcessLauncher != null) + { + await runtimeProcessLauncher.DisposeAsync(); + } + + if (waitForFileChangeBeforeRestarting && + !shutdownCancellationToken.IsCancellationRequested && + !forceRestartCancellationSource.IsCancellationRequested && + rootRunningProject?.IsRestarting != true) + { + using var shutdownOrForcedRestartSource = CancellationTokenSource.CreateLinkedTokenSource(shutdownCancellationToken, forceRestartCancellationSource.Token); + await WaitForFileChangeBeforeRestarting(fileWatcher, evaluationResult, shutdownOrForcedRestartSource.Token); + } + } + } + } + + private void AnalyzeFileChanges( + List changedFiles, + EvaluationResult evaluationResult, + out bool evaluationRequired) + { + // If any build file changed (project, props, targets) we need to re-evaluate the projects. + // Currently we re-evaluate the whole project graph even if only a single project file changed. + if (changedFiles.Select(f => f.Item.FilePath).FirstOrDefault(path => evaluationResult.BuildFiles.Contains(path) || MatchesBuildFile(path)) is { } firstBuildFilePath) + { + _context.Logger.Log(MessageDescriptor.ProjectChangeTriggeredReEvaluation, firstBuildFilePath); + evaluationRequired = true; + return; + } + + for (var i = 0; i < changedFiles.Count; i++) + { + var changedFile = changedFiles[i]; + var filePath = changedFile.Item.FilePath; + + if (changedFile.Kind is ChangeKind.Add) + { + if (MatchesStaticWebAssetFilePattern(evaluationResult, filePath, out var staticWebAssetUrl)) + { + changedFiles[i] = changedFile with + { + Item = changedFile.Item with { StaticWebAssetRelativeUrl = staticWebAssetUrl } + }; + } + else + { + // TODO: https://github.com/dotnet/sdk/issues/52390 + // Get patterns from evaluation that match Compile, AdditionalFile, AnalyzerConfigFile items. + // Avoid re-evaluating on addition of files that don't affect the project. + + // project file or other file: + _context.Logger.Log(MessageDescriptor.FileAdditionTriggeredReEvaluation, filePath); + evaluationRequired = true; + return; + } + } + } + + evaluationRequired = false; + } + + /// + /// True if the file path looks like a file that might be imported by MSBuild. + /// + private static bool MatchesBuildFile(string filePath) + { + var extension = Path.GetExtension(filePath); + return extension.Equals(".props", PathUtilities.OSSpecificPathComparison) + || extension.Equals(".targets", PathUtilities.OSSpecificPathComparison) + || extension.EndsWith("proj", PathUtilities.OSSpecificPathComparison) + || extension.Equals("projitems", PathUtilities.OSSpecificPathComparison) // shared project items + || string.Equals(Path.GetFileName(filePath), "global.json", PathUtilities.OSSpecificPathComparison); + } + + /// + /// Determines if the given file path is a static web asset file path based on + /// the discovery patterns. + /// + private static bool MatchesStaticWebAssetFilePattern(EvaluationResult evaluationResult, string filePath, out string? staticWebAssetUrl) + { + staticWebAssetUrl = null; + + if (StaticWebAsset.IsScopedCssFile(filePath)) + { + return true; + } + + foreach (var (_, manifest) in evaluationResult.StaticWebAssetsManifests) + { + foreach (var pattern in manifest.DiscoveryPatterns) + { + var match = pattern.Glob.MatchInfo(filePath); + if (match.IsMatch) + { + var dirUrl = match.WildcardDirectoryPartMatchGroup.Replace(Path.DirectorySeparatorChar, '/'); + + Debug.Assert(!dirUrl.EndsWith('/')); + Debug.Assert(!pattern.BaseUrl.EndsWith('/')); + + var url = UrlEncoder.Default.Encode(dirUrl + "/" + match.FilenamePartMatchGroup); + if (pattern.BaseUrl != "") + { + url = pattern.BaseUrl + "/" + url; + } + + staticWebAssetUrl = url; + return true; + } + } + } + + return false; + } + + private void DeployProjectDependencies(ImmutableArray restoredProjectInstances, ImmutableArray projectPaths, CancellationToken cancellationToken) + { + var projectPathSet = projectPaths.ToImmutableHashSet(PathUtilities.OSSpecificPathComparer); + var buildReporter = new BuildReporter(_context.Logger, _context.Options, _context.EnvironmentOptions); + var targetName = TargetNames.ReferenceCopyLocalPathsOutputGroup; + + foreach (var restoredProjectInstance in restoredProjectInstances) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Avoid modification of the restored snapshot. + var projectInstance = restoredProjectInstance.DeepCopy(); + + var projectPath = projectInstance.FullPath; + + if (!projectPathSet.Contains(projectPath)) + { + continue; + } + + if (!projectInstance.Targets.ContainsKey(targetName)) + { + continue; + } + + if (projectInstance.GetOutputDirectory() is not { } relativeOutputDir) + { + continue; + } + + using var loggers = buildReporter.GetLoggers(projectPath, targetName); + if (!projectInstance.Build([targetName], loggers, out var targetOutputs)) + { + _context.Logger.LogDebug("{TargetName} target failed", targetName); + loggers.ReportOutput(); + continue; + } + + var outputDir = Path.Combine(Path.GetDirectoryName(projectPath)!, relativeOutputDir); + + foreach (var item in targetOutputs[targetName].Items) + { + cancellationToken.ThrowIfCancellationRequested(); + + var sourcePath = item.ItemSpec; + var targetPath = Path.Combine(outputDir, item.GetMetadata(MetadataNames.TargetPath)); + if (!File.Exists(targetPath)) + { + _context.Logger.LogDebug("Deploying project dependency '{TargetPath}' from '{SourcePath}'", targetPath, sourcePath); + + try + { + var directory = Path.GetDirectoryName(targetPath); + if (directory != null) + { + Directory.CreateDirectory(directory); + } + + File.Copy(sourcePath, targetPath, overwrite: false); + } + catch (Exception e) + { + _context.Logger.LogDebug("Copy failed: {Message}", e.Message); + } + } + } + } + } + + private async ValueTask WaitForFileChangeBeforeRestarting(FileWatcher fileWatcher, EvaluationResult? evaluationResult, CancellationToken cancellationToken) + { + if (evaluationResult != null) + { + if (!fileWatcher.WatchingDirectories) + { + evaluationResult.WatchFiles(fileWatcher); + } + + _ = await fileWatcher.WaitForFileChangeAsync( + evaluationResult.Files, + startedWatching: () => _context.Logger.Log(MessageDescriptor.WaitingForFileChangeBeforeRestarting), + cancellationToken); + } + else + { + // evaluation cancelled - watch for any changes in the directory tree containing the root project or entry-point file: + fileWatcher.WatchContainingDirectories([_context.RootProjectOptions.Representation.ProjectOrEntryPointFilePath], includeSubdirectories: true); + + _ = await fileWatcher.WaitForFileChangeAsync( + acceptChange: AcceptChange, + startedWatching: () => _context.Logger.Log(MessageDescriptor.WaitingForFileChangeBeforeRestarting), + cancellationToken); + } + } + + private bool AcceptChange(ChangedPath change, EvaluationResult evaluationResult) + { + var (path, kind) = change; + + // Handle changes to files that are known to be project build inputs from its evaluation. + // Compile items might be explicitly added by targets to directories that are excluded by default + // (e.g. global usings in obj directory). Changes to these files should not be ignored. + if (evaluationResult.Files.ContainsKey(path)) + { + return true; + } + + if (!AcceptChange(change)) + { + return false; + } + + // changes in *.*proj, *.props, *.targets: + if (evaluationResult.BuildFiles.Contains(path)) + { + return true; + } + + // Ignore other changes that match DefaultItemExcludes glob if EnableDefaultItems is true, + // otherwise changes under output and intermediate output directories. + // + // Unsupported scenario: + // - msbuild target adds source files to intermediate output directory and Compile items + // based on the content of non-source file. + // + // On the other hand, changes to source files produced by source generators will be registered + // since the changes to additional file will trigger workspace update, which will trigger the source generator. + return !evaluationResult.ItemExclusions.IsExcluded(path, kind, _context.Logger); + } + + private bool AcceptChange(ChangedPath change) + { + var (path, kind) = change; + + if (Path.GetExtension(path) == ".binlog") + { + return false; + } + + if (PathUtilities.GetContainingDirectories(path).FirstOrDefault(IsHiddenDirectory) is { } containingHiddenDir) + { + _context.Logger.Log(MessageDescriptor.IgnoringChangeInHiddenDirectory, containingHiddenDir, kind, path); + return false; + } + + return true; + } + + // Directory name starts with '.' on Unix is considered hidden. + // Apply the same convention on Windows as well (instead of checking for hidden attribute). + // This is consistent with SDK rules for default item exclusions: + // https://github.com/dotnet/sdk/blob/124be385f90f2c305dde2b817cb470e4d11d2d6b/src/Tasks/Microsoft.NET.Build.Tasks/targets/Microsoft.NET.Sdk.DefaultItems.targets#L42 + private static bool IsHiddenDirectory(string dir) + => Path.GetFileName(dir).StartsWith('.'); + + internal static IEnumerable NormalizePathChanges(IEnumerable changes) + => changes + .GroupBy(keySelector: change => change.Path) + .Select(group => + { + ChangedPath? lastUpdate = null; + ChangedPath? lastDelete = null; + ChangedPath? lastAdd = null; + ChangedPath? previous = null; + + foreach (var item in group) + { + // eliminate repeated changes: + if (item.Kind == previous?.Kind) + { + continue; + } + + previous = item; + + if (item.Kind == ChangeKind.Add) + { + // eliminate delete-(update)*-add: + if (lastDelete.HasValue) + { + lastDelete = null; + lastAdd = null; + lastUpdate ??= item with { Kind = ChangeKind.Update }; + } + else + { + lastAdd = item; + } + } + else if (item.Kind == ChangeKind.Delete) + { + // eliminate add-delete: + if (lastAdd.HasValue) + { + lastDelete = null; + lastAdd = null; + } + else + { + lastDelete = item; + + // eliminate previous update: + lastUpdate = null; + } + } + else if (item.Kind == ChangeKind.Update) + { + // ignore updates after add: + if (!lastAdd.HasValue) + { + lastUpdate = item; + } + } + else + { + throw new InvalidOperationException($"Unexpected change kind: {item.Kind}"); + } + } + + return lastDelete ?? lastAdd ?? lastUpdate; + }) + .Where(item => item != null) + .Select(item => item!.Value); + + private void ReportFileChanges(IReadOnlyList changedFiles) + { + Report(kind: ChangeKind.Add); + Report(kind: ChangeKind.Update); + Report(kind: ChangeKind.Delete); + + void Report(ChangeKind kind) + { + var items = changedFiles.Where(item => item.Kind == kind).ToArray(); + if (items is not []) + { + _context.Logger.LogInformation(GetMessage(items, kind)); + } + } + + string GetMessage(IReadOnlyList items, ChangeKind kind) + => items is [{Item: var item }] + ? GetSingularMessage(kind) + ": " + GetRelativeFilePath(item.FilePath) + : GetPluralMessage(kind) + ": " + string.Join(", ", items.Select(f => GetRelativeFilePath(f.Item.FilePath))); + + static string GetSingularMessage(ChangeKind kind) + => kind switch + { + ChangeKind.Update => "File updated", + ChangeKind.Add => "File added", + ChangeKind.Delete => "File deleted", + _ => throw new InvalidOperationException() + }; + + static string GetPluralMessage(ChangeKind kind) + => kind switch + { + ChangeKind.Update => "Files updated", + ChangeKind.Add => "Files added", + ChangeKind.Delete => "Files deleted", + _ => throw new InvalidOperationException() + }; + } + + private async ValueTask EvaluateRootProjectAsync(bool restore, CancellationToken cancellationToken) + { + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + + _context.Logger.LogInformation("Evaluating projects ..."); + var stopwatch = Stopwatch.StartNew(); + + var result = EvaluationResult.TryCreate( + _designTimeBuildGraphFactory, + _context.BuildLogger, + _context.Options, + _context.EnvironmentOptions, + restore, + cancellationToken); + + _context.Logger.LogInformation("Evaluation completed in {Time}s.", stopwatch.Elapsed.TotalSeconds.ToString("0.0")); + + if (result != null) + { + return result; + } + + await FileWatcher.WaitForFileChangeAsync( + _context.RootProjectOptions.Representation.ProjectOrEntryPointFilePath, + _context.Logger, + _context.EnvironmentOptions, + startedWatching: () => _context.Logger.Log(MessageDescriptor.FixBuildError), + cancellationToken); + } + } + + private async Task BuildProjectAsync(ProjectRepresentation project, IReadOnlyList buildArguments, CancellationToken cancellationToken) + { + List? capturedOutput = _context.EnvironmentOptions.TestFlags != TestFlags.None ? [] : null; + + var processSpec = new ProcessSpec + { + Executable = _context.EnvironmentOptions.MuxerPath, + WorkingDirectory = project.GetContainingDirectory(), + IsUserApplication = false, + + // Capture output if running in a test environment. + // If the output is not captured dotnet build will show live build progress. + OnOutput = capturedOutput != null + ? line => + { + lock (capturedOutput) + { + capturedOutput.Add(line); + } + } + : null, + + // pass user-specified build arguments last to override defaults: + Arguments = ["build", project.ProjectOrEntryPointFilePath, .. buildArguments] + }; + + _context.BuildLogger.Log(MessageDescriptor.Building, project.ProjectOrEntryPointFilePath); + + var success = await _context.ProcessRunner.RunAsync(processSpec, _context.Logger, launchResult: null, cancellationToken) == 0; + + if (capturedOutput != null) + { + _context.BuildLogger.Log(success ? MessageDescriptor.BuildSucceeded : MessageDescriptor.BuildFailed, project.ProjectOrEntryPointFilePath); + BuildOutput.ReportBuildOutput(_context.BuildLogger, capturedOutput, success); + } + + return success; + } + + private string GetRelativeFilePath(string path) + { + var relativePath = path; + var workingDirectory = _context.EnvironmentOptions.WorkingDirectory; + if (path.StartsWith(workingDirectory, StringComparison.Ordinal) && path.Length > workingDirectory.Length) + { + relativePath = path.Substring(workingDirectory.Length); + + return $".{(relativePath.StartsWith(Path.DirectorySeparatorChar) ? string.Empty : Path.DirectorySeparatorChar)}{relativePath}"; + } + + return relativePath; + } + } +} diff --git a/src/WatchPrototype/Watch/HotReload/HotReloadEventSource.cs b/src/WatchPrototype/Watch/HotReload/HotReloadEventSource.cs new file mode 100644 index 00000000000..4196f85ba21 --- /dev/null +++ b/src/WatchPrototype/Watch/HotReload/HotReloadEventSource.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.Diagnostics.Tracing; + +namespace Microsoft.DotNet.Watch +{ + [EventSource(Name = "HotReload")] + internal sealed class HotReloadEventSource : EventSource + { + public enum StartType + { + Main, + StaticHandler, + CompilationHandler, + } + + internal sealed class Keywords + { + public const EventKeywords Perf = (EventKeywords)1; + } + + [Event(1, Message = "Hot reload started for {0}", Level = EventLevel.Informational, Keywords = Keywords.Perf)] + public void HotReloadStart(StartType handlerType) { WriteEvent(1, handlerType); } + + [Event(2, Message = "Hot reload finished for {0}", Level = EventLevel.Informational, Keywords = Keywords.Perf)] + public void HotReloadEnd(StartType handlerType) { WriteEvent(2, handlerType); } + + public static readonly HotReloadEventSource Log = new(); + } +} diff --git a/src/WatchPrototype/Watch/Microsoft.DotNet.HotReload.Watch.csproj b/src/WatchPrototype/Watch/Microsoft.DotNet.HotReload.Watch.csproj new file mode 100644 index 00000000000..282250d8e49 --- /dev/null +++ b/src/WatchPrototype/Watch/Microsoft.DotNet.HotReload.Watch.csproj @@ -0,0 +1,41 @@ + + + + + + + + + $(SdkTargetFramework) + Library + Hot Reload watch implementation + Microsoft.DotNet.Watch + MicrosoftAspNetCore + + + $(NoWarn);CS9057 + + + + + + + + + + + + + + + + + + diff --git a/src/WatchPrototype/Watch/Process/IRuntimeProcessLauncher.cs b/src/WatchPrototype/Watch/Process/IRuntimeProcessLauncher.cs new file mode 100644 index 00000000000..f91f9342155 --- /dev/null +++ b/src/WatchPrototype/Watch/Process/IRuntimeProcessLauncher.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. + +namespace Microsoft.DotNet.Watch; + +/// +/// Process launcher that triggers process launches at runtime of the watched application, +/// as opposed to design-time configuration given on command line or by the project system. +/// +internal interface IRuntimeProcessLauncher : IAsyncDisposable +{ + IEnumerable<(string name, string value)> GetEnvironmentVariables(); + + /// + /// Initiates shutdown. Terminates all created processes. + /// + ValueTask TerminateLaunchedProcessesAsync(CancellationToken cancellationToken); +} diff --git a/src/WatchPrototype/Watch/Process/IRuntimeProcessLauncherFactory.cs b/src/WatchPrototype/Watch/Process/IRuntimeProcessLauncherFactory.cs new file mode 100644 index 00000000000..93d69f69db5 --- /dev/null +++ b/src/WatchPrototype/Watch/Process/IRuntimeProcessLauncherFactory.cs @@ -0,0 +1,16 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Build.Graph; + +namespace Microsoft.DotNet.Watch; + +/// +/// Creates for a given root project. +/// This gives dotnet-watch the ability to watch for and apply changes to +/// child processes that the root project application launches. +/// +internal interface IRuntimeProcessLauncherFactory +{ + public IRuntimeProcessLauncher? TryCreate(ProjectGraphNode projectNode, ProjectLauncher projectLauncher, ProjectOptions hostProjectOptions); +} diff --git a/src/WatchPrototype/Watch/Process/LaunchSettingsProfile.cs b/src/WatchPrototype/Watch/Process/LaunchSettingsProfile.cs new file mode 100644 index 00000000000..08b30510b58 --- /dev/null +++ b/src/WatchPrototype/Watch/Process/LaunchSettingsProfile.cs @@ -0,0 +1,120 @@ +// 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; +using System.Text.Json.Serialization; +using Microsoft.DotNet.ProjectTools; +using Microsoft.Extensions.Logging; + +namespace Microsoft.DotNet.Watch +{ + internal sealed class LaunchSettingsProfile + { + private static readonly JsonSerializerOptions s_serializerOptions = new(JsonSerializerDefaults.Web) + { + AllowTrailingCommas = true, + ReadCommentHandling = JsonCommentHandling.Skip, + }; + + [JsonIgnore] + public string? LaunchProfileName { get; set; } + public string? ApplicationUrl { get; init; } + public string? CommandName { get; init; } + public bool LaunchBrowser { get; init; } + public string? LaunchUrl { get; init; } + + internal static LaunchSettingsProfile? ReadLaunchProfile(ProjectRepresentation project, string? launchProfileName, ILogger logger) + { + var launchSettingsPath = LaunchSettings.TryFindLaunchSettingsFile(project.ProjectOrEntryPointFilePath, launchProfileName, (message, isError) => + { + if (isError) + { + logger.LogError(message); + } + else + { + logger.LogWarning(message); + } + }); + + if (launchSettingsPath == null) + { + return null; + } + + LaunchSettingsJson? launchSettings; + try + { + launchSettings = JsonSerializer.Deserialize( + File.ReadAllText(launchSettingsPath), + s_serializerOptions); + } + catch (Exception e) + { + logger.LogDebug("Error reading '{Path}': {Message}.", launchSettingsPath, e.Message); + return null; + } + + if (string.IsNullOrEmpty(launchProfileName)) + { + // Load the default (first) launch profile + return ReadDefaultLaunchProfile(launchSettings, logger); + } + + // Load the specified launch profile + var namedProfile = launchSettings?.Profiles?.FirstOrDefault(kvp => + string.Equals(kvp.Key, launchProfileName, StringComparison.Ordinal)).Value; + + if (namedProfile is null) + { + logger.LogWarning("Unable to find launch profile with name '{ProfileName}'. Falling back to default profile.", launchProfileName); + + // Check if a case-insensitive match exists + var caseInsensitiveNamedProfile = launchSettings?.Profiles?.FirstOrDefault(kvp => + string.Equals(kvp.Key, launchProfileName, StringComparison.OrdinalIgnoreCase)).Key; + + if (caseInsensitiveNamedProfile is not null) + { + logger.LogWarning("Note: Launch profile names are case-sensitive. Did you mean '{ProfileName}'?", caseInsensitiveNamedProfile); + } + + return ReadDefaultLaunchProfile(launchSettings, logger); + } + + logger.LogDebug("Found named launch profile '{ProfileName}'.", launchProfileName); + namedProfile.LaunchProfileName = launchProfileName; + return namedProfile; + } + + private static LaunchSettingsProfile? ReadDefaultLaunchProfile(LaunchSettingsJson? launchSettings, ILogger logger) + { + if (launchSettings is null || launchSettings.Profiles is null) + { + logger.LogDebug("Unable to find default launch profile."); + return null; + } + + // Look for the first profile with a supported command name + // Note: These must match the command names supported by LaunchSettingsManager in src/Cli/dotnet/Commands/Run/LaunchSettings/ + var supportedCommandNames = new[] { "Project", "Executable" }; + var defaultProfileKey = launchSettings.Profiles.FirstOrDefault(entry => + entry.Value.CommandName != null && supportedCommandNames.Contains(entry.Value.CommandName, StringComparer.Ordinal)).Key; + + if (defaultProfileKey is null) + { + logger.LogDebug("Unable to find a supported command name in the default launch profile. Supported types: {SupportedTypes}", + string.Join(", ", supportedCommandNames)); + return null; + } + + var defaultProfile = launchSettings.Profiles[defaultProfileKey]; + defaultProfile.LaunchProfileName = defaultProfileKey; + return defaultProfile; + } + + internal class LaunchSettingsJson + { + public OrderedDictionary? Profiles { get; set; } + } + } +} diff --git a/src/WatchPrototype/Watch/Process/ProcessLaunchResult.cs b/src/WatchPrototype/Watch/Process/ProcessLaunchResult.cs new file mode 100644 index 00000000000..6084bc11b21 --- /dev/null +++ b/src/WatchPrototype/Watch/Process/ProcessLaunchResult.cs @@ -0,0 +1,10 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.DotNet.Watch +{ + internal sealed class ProcessLaunchResult + { + public int? ProcessId { get; set; } + } +} diff --git a/src/WatchPrototype/Watch/Process/ProcessRunner.cs b/src/WatchPrototype/Watch/Process/ProcessRunner.cs new file mode 100644 index 00000000000..2cfba7ade09 --- /dev/null +++ b/src/WatchPrototype/Watch/Process/ProcessRunner.cs @@ -0,0 +1,402 @@ +// 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 Microsoft.Extensions.Logging; + +namespace Microsoft.DotNet.Watch +{ + internal sealed class ProcessRunner(TimeSpan processCleanupTimeout) + { + private sealed class ProcessState(Process process) : IDisposable + { + public Process Process { get; } = process; + + public int ProcessId; + public bool HasExited; + + // True if Ctrl+C was sent to the process on Windows. + public bool SentWindowsCtrlC; + + // True if SIGKILL was sent to the process on Unix. + public bool SentUnixSigKill; + + public void Dispose() + => Process.Dispose(); + } + + private const int CtlrCExitCode = unchecked((int)0xC000013A); + private const int SigKillExitCode = 137; + + // For testing purposes only, lock on access. + private static readonly HashSet s_runningApplicationProcesses = []; + + public static IReadOnlyCollection GetRunningApplicationProcesses() + { + lock (s_runningApplicationProcesses) + { + return [.. s_runningApplicationProcesses]; + } + } + + /// + /// Launches a process. + /// + public async Task RunAsync(ProcessSpec processSpec, ILogger logger, ProcessLaunchResult? launchResult, CancellationToken processTerminationToken) + { + var stopwatch = new Stopwatch(); + stopwatch.Start(); + + using var state = TryStartProcessImpl(processSpec, logger); + if (state == null) + { + return int.MinValue; + } + + if (processSpec.IsUserApplication) + { + lock (s_runningApplicationProcesses) + { + s_runningApplicationProcesses.Add(state.ProcessId); + } + } + + launchResult?.ProcessId = state.ProcessId; + + int? exitCode = null; + + try + { + try + { + await state.Process.WaitForExitAsync(processTerminationToken); + } + catch (OperationCanceledException) + { + // Process termination requested via cancellation token. + // Either Ctrl+C was pressed or the process is being restarted. + + // Non-cancellable to not leave orphaned processes around blocking resources: + await TerminateProcessAsync(state.Process, processSpec, state, logger, CancellationToken.None); + } + } + catch (Exception e) + { + if (processSpec.IsUserApplication) + { + logger.Log(MessageDescriptor.ApplicationFailed, e.Message); + } + } + finally + { + stopwatch.Stop(); + + if (processSpec.IsUserApplication) + { + lock (s_runningApplicationProcesses) + { + s_runningApplicationProcesses.Remove(state.ProcessId); + } + } + + state.HasExited = true; + + try + { + exitCode = state.Process.ExitCode; + } + catch + { + exitCode = null; + } + + logger.Log(MessageDescriptor.ProcessRunAndExited, state.ProcessId, stopwatch.ElapsedMilliseconds, exitCode); + + if (processSpec.IsUserApplication) + { + if (exitCode == 0 || + state.SentWindowsCtrlC && exitCode == CtlrCExitCode || + state.SentUnixSigKill && exitCode == SigKillExitCode) + { + logger.Log(MessageDescriptor.Exited); + } + else if (exitCode == null) + { + logger.Log(MessageDescriptor.ExitedWithUnknownErrorCode); + } + else + { + logger.Log(MessageDescriptor.ExitedWithErrorCode, exitCode); + } + } + + if (processSpec.OnExit != null) + { + await processSpec.OnExit(state.ProcessId, exitCode); + } + } + + return exitCode ?? int.MinValue; + } + + internal static Process? TryStartProcess(ProcessSpec processSpec, ILogger logger) + => TryStartProcessImpl(processSpec, logger)?.Process; + + private static ProcessState? TryStartProcessImpl(ProcessSpec processSpec, ILogger logger) + { + var onOutput = processSpec.OnOutput; + + var process = new Process + { + EnableRaisingEvents = true, + StartInfo = + { + FileName = processSpec.Executable, + UseShellExecute = processSpec.UseShellExecute, + WorkingDirectory = processSpec.WorkingDirectory, + RedirectStandardOutput = onOutput != null, + RedirectStandardError = onOutput != null, + } + }; + + var state = new ProcessState(process); + + if (processSpec.IsUserApplication && RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + process.StartInfo.CreateNewProcessGroup = true; + } + + if (processSpec.EscapedArguments is not null) + { + process.StartInfo.Arguments = processSpec.EscapedArguments; + } + else if (processSpec.Arguments is not null) + { + for (var i = 0; i < processSpec.Arguments.Count; i++) + { + process.StartInfo.ArgumentList.Add(processSpec.Arguments[i]); + } + } + + foreach (var env in processSpec.EnvironmentVariables) + { + process.StartInfo.Environment.Add(env.Key, env.Value); + } + + if (onOutput != null) + { + process.OutputDataReceived += (_, args) => + { + try + { + if (args.Data != null) + { + onOutput(new OutputLine(args.Data, IsError: false)); + } + } + catch (Exception e) + { + logger.Log(MessageDescriptor.ErrorReadingProcessOutput, "stdout", state.ProcessId, e.Message); + } + }; + + process.ErrorDataReceived += (_, args) => + { + try + { + if (args.Data != null) + { + onOutput(new OutputLine(args.Data, IsError: true)); + } + } + catch (Exception e) + { + logger.Log(MessageDescriptor.ErrorReadingProcessOutput, "stderr", state.ProcessId, e.Message); + } + }; + } + + var argsDisplay = processSpec.GetArgumentsDisplay(); + + try + { + if (!process.Start()) + { + throw new InvalidOperationException("Process can't be started."); + } + state.ProcessId = process.Id; + + if (onOutput != null) + { + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + } + + logger.Log(MessageDescriptor.LaunchedProcess, processSpec.Executable, argsDisplay, state.ProcessId); + return state; + } + catch (Exception e) + { + logger.Log(MessageDescriptor.FailedToLaunchProcess, processSpec.Executable, argsDisplay, e.Message); + + state.Dispose(); + return null; + } + } + + private async ValueTask TerminateProcessAsync(Process process, ProcessSpec processSpec, ProcessState state, ILogger logger, CancellationToken cancellationToken) + { + var forceOnly = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && !processSpec.IsUserApplication; + + TerminateProcess(process, state, logger, forceOnly); + + if (forceOnly) + { + _ = await WaitForExitAsync(process, state, timeout: null, logger, cancellationToken); + return; + } + + // Ctlr+C/SIGTERM has been sent, wait for the process to exit gracefully. + if (processCleanupTimeout.TotalMilliseconds == 0 || + !await WaitForExitAsync(process, state, processCleanupTimeout, logger, cancellationToken)) + { + // Force termination if the process is still running after the timeout. + TerminateProcess(process, state, logger, force: true); + + _ = await WaitForExitAsync(process, state, timeout: null, logger, cancellationToken); + } + } + + private static async ValueTask WaitForExitAsync(Process process, ProcessState state, TimeSpan? timeout, ILogger logger, CancellationToken cancellationToken) + { + // On Linux simple call WaitForExitAsync does not work reliably (it may hang). + // As a workaround we poll for HasExited. + // See also https://github.com/dotnet/runtime/issues/109434. + + var task = process.WaitForExitAsync(cancellationToken); + + if (timeout is { } timeoutValue) + { + try + { + logger.Log(MessageDescriptor.WaitingForProcessToExitWithin, state.ProcessId, timeoutValue.TotalSeconds); + await task.WaitAsync(timeoutValue, cancellationToken); + } + catch (TimeoutException) + { + try + { + return process.HasExited; + } + catch + { + return false; + } + } + } + else + { + int i = 1; + while (true) + { + try + { + if (process.HasExited) + { + return true; + } + } + catch + { + } + + logger.Log(MessageDescriptor.WaitingForProcessToExit, state.ProcessId, i++); + + try + { + await task.WaitAsync(TimeSpan.FromSeconds(1), cancellationToken); + break; + } + catch (TimeoutException) + { + } + } + } + + return true; + } + + private static void TerminateProcess(Process process, ProcessState state, ILogger logger, bool force) + { + try + { + if (!state.HasExited && !process.HasExited) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + TerminateWindowsProcess(process, state, logger, force); + } + else + { + TerminateUnixProcess(state, logger, force); + } + } + } + catch (Exception e) + { + logger.Log(MessageDescriptor.FailedToKillProcess, state.ProcessId, e.Message); + } + } + + private static void TerminateWindowsProcess(Process process, ProcessState state, ILogger logger, bool force) + { + var signalName = force ? "Kill" : "Ctrl+C"; + logger.Log(MessageDescriptor.TerminatingProcess, state.ProcessId, signalName); + + if (force) + { + try + { + process.Kill(); + } + catch (Exception e) + { + logger.Log(MessageDescriptor.FailedToSendSignalToProcess, signalName, state.ProcessId, e.Message); + } + } + else + { + state.SentWindowsCtrlC = true; + + var error = ProcessUtilities.SendWindowsCtrlCEvent(state.ProcessId); + if (error != null) + { + state.SentWindowsCtrlC = false; + logger.Log(MessageDescriptor.FailedToSendSignalToProcess, signalName, state.ProcessId, error); + } + } + } + + private static void TerminateUnixProcess(ProcessState state, ILogger logger, bool force) + { + var signalName = force ? "SIGKILL" : "SIGTERM"; + logger.Log(MessageDescriptor.TerminatingProcess, state.ProcessId, signalName); + + if (force) + { + state.SentUnixSigKill = true; + } + + var error = ProcessUtilities.SendPosixSignal(state.ProcessId, signal: force ? ProcessUtilities.SIGKILL : ProcessUtilities.SIGTERM); + if (error != null) + { + if (force) + { + state.SentUnixSigKill = false; + } + + logger.Log(MessageDescriptor.FailedToSendSignalToProcess, signalName, state.ProcessId, error); + } + } + } +} diff --git a/src/WatchPrototype/Watch/Process/ProcessSpec.cs b/src/WatchPrototype/Watch/Process/ProcessSpec.cs new file mode 100644 index 00000000000..15e4f1a12eb --- /dev/null +++ b/src/WatchPrototype/Watch/Process/ProcessSpec.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. + +namespace Microsoft.DotNet.Watch +{ + internal sealed class ProcessSpec + { + public string? Executable { get; set; } + public string? WorkingDirectory { get; set; } + public Dictionary EnvironmentVariables { get; } = []; + public IReadOnlyList? Arguments { get; set; } + public string? EscapedArguments { get; set; } + public Action? OnOutput { get; set; } + public ProcessExitAction? OnExit { get; set; } + public CancellationToken CancelOutputCapture { get; set; } + public bool UseShellExecute { get; set; } + + /// + /// True if the process is a user application, false if it is a helper process (e.g. dotnet build). + /// + public bool IsUserApplication { get; set; } + + public string? ShortDisplayName() + => Path.GetFileNameWithoutExtension(Executable); + + public string GetArgumentsDisplay() + => EscapedArguments ?? CommandLineUtilities.JoinArguments(Arguments ?? []); + } +} diff --git a/src/WatchPrototype/Watch/Process/ProjectLauncher.cs b/src/WatchPrototype/Watch/Process/ProjectLauncher.cs new file mode 100644 index 00000000000..e3e97b3da4f --- /dev/null +++ b/src/WatchPrototype/Watch/Process/ProjectLauncher.cs @@ -0,0 +1,135 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Globalization; +using Microsoft.DotNet.HotReload; +using Microsoft.Extensions.Logging; + +namespace Microsoft.DotNet.Watch; + +internal delegate ValueTask ProcessExitAction(int processId, int? exitCode); + +internal sealed class ProjectLauncher( + DotNetWatchContext context, + ProjectNodeMap projectMap, + CompilationHandler compilationHandler, + int iteration) +{ + public int Iteration = iteration; + + public ILogger Logger + => context.Logger; + + public ILoggerFactory LoggerFactory + => context.LoggerFactory; + + public EnvironmentOptions EnvironmentOptions + => context.EnvironmentOptions; + + public async ValueTask TryLaunchProcessAsync( + ProjectOptions projectOptions, + CancellationTokenSource processTerminationSource, + Action? onOutput, + ProcessExitAction? onExit, + RestartOperation restartOperation, + CancellationToken cancellationToken) + { + var projectNode = projectMap.TryGetProjectNode(projectOptions.Representation.ProjectGraphPath, projectOptions.TargetFramework); + if (projectNode == null) + { + // error already reported + return null; + } + + if (!projectNode.IsNetCoreApp(Versions.Version6_0)) + { + Logger.LogError($"Hot Reload based watching is only supported in .NET 6.0 or newer apps. Use --no-hot-reload switch or update the project's launchSettings.json to disable this feature."); + return null; + } + + var appModel = HotReloadAppModel.InferFromProject(context, projectNode); + + // create loggers that include project name in messages: + var projectDisplayName = projectNode.GetDisplayName(); + var clientLogger = context.LoggerFactory.CreateLogger(HotReloadDotNetWatcher.ClientLogComponentName, projectDisplayName); + var agentLogger = context.LoggerFactory.CreateLogger(HotReloadDotNetWatcher.AgentLogComponentName, projectDisplayName); + + var clients = await appModel.TryCreateClientsAsync(clientLogger, agentLogger, cancellationToken); + if (clients == null) + { + // error already reported + return null; + } + + var processSpec = new ProcessSpec + { + Executable = EnvironmentOptions.MuxerPath, + IsUserApplication = true, + WorkingDirectory = projectOptions.WorkingDirectory, + OnOutput = onOutput, + OnExit = onExit, + }; + + // Stream output lines to the process output reporter. + // The reporter synchronizes the output of the process with the logger output, + // so that the printed lines don't interleave. + // Only send the output to the reporter if no custom output handler was provided (e.g. for Aspire child processes). + processSpec.OnOutput ??= line => + { + context.ProcessOutputReporter.ReportOutput(context.ProcessOutputReporter.PrefixProcessOutput ? line with { Content = $"[{projectDisplayName}] {line.Content}" } : line); + }; + + var environmentBuilder = new Dictionary(); + + // initialize with project settings: + foreach (var (name, value) in projectOptions.LaunchEnvironmentVariables) + { + environmentBuilder[name] = value; + } + + // override any project settings: + environmentBuilder[EnvironmentVariables.Names.DotnetWatch] = "1"; + environmentBuilder[EnvironmentVariables.Names.DotnetWatchIteration] = (Iteration + 1).ToString(CultureInfo.InvariantCulture); + + if (Logger.IsEnabled(LogLevel.Trace)) + { + environmentBuilder[EnvironmentVariables.Names.HotReloadDeltaClientLogMessages] = + (EnvironmentOptions.SuppressEmojis ? Emoji.Default : Emoji.Agent).GetLogMessagePrefix() + $"[{projectDisplayName}]"; + } + + clients.ConfigureLaunchEnvironment(environmentBuilder); + + processSpec.Arguments = GetProcessArguments(projectOptions, environmentBuilder); + + // Attach trigger to the process that detects when the web server reports to the output that it's listening. + // Launches browser on the URL found in the process output for root projects. + context.BrowserLauncher.InstallBrowserLaunchTrigger(processSpec, projectNode, projectOptions, clients.BrowserRefreshServer, cancellationToken); + + return await compilationHandler.TrackRunningProjectAsync( + projectNode, + projectOptions, + clients, + processSpec, + restartOperation, + processTerminationSource, + cancellationToken); + } + + private static IReadOnlyList GetProcessArguments(ProjectOptions projectOptions, IDictionary environmentBuilder) + { + var arguments = new List() + { + projectOptions.Command, + "--no-build" + }; + + foreach (var (name, value) in environmentBuilder) + { + arguments.Add("-e"); + arguments.Add($"{name}={value}"); + } + + arguments.AddRange(projectOptions.CommandArguments); + return arguments; + } +} diff --git a/src/WatchPrototype/Watch/Process/RunningProject.cs b/src/WatchPrototype/Watch/Process/RunningProject.cs new file mode 100644 index 00000000000..c4d63e953a2 --- /dev/null +++ b/src/WatchPrototype/Watch/Process/RunningProject.cs @@ -0,0 +1,133 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Immutable; +using System.Diagnostics; +using Microsoft.Build.Graph; +using Microsoft.DotNet.HotReload; +using Microsoft.Extensions.Logging; + +namespace Microsoft.DotNet.Watch +{ + internal delegate ValueTask RestartOperation(CancellationToken cancellationToken); + + internal sealed class RunningProject( + ProjectGraphNode projectNode, + ProjectOptions options, + HotReloadClients clients, + Task runningProcess, + int processId, + CancellationTokenSource processExitedSource, + CancellationTokenSource processTerminationSource, + RestartOperation restartOperation, + ImmutableArray capabilities) : IDisposable + { + public readonly ProjectGraphNode ProjectNode = projectNode; + public readonly ProjectOptions Options = options; + public readonly HotReloadClients Clients = clients; + public readonly ImmutableArray Capabilities = capabilities; + public readonly Task RunningProcess = runningProcess; + public readonly int ProcessId = processId; + public readonly RestartOperation RestartOperation = restartOperation; + + /// + /// Cancellation token triggered when the process exits. + /// Stores the token to allow callers to use the token even after the source has been disposed. + /// + public CancellationToken ProcessExitedCancellationToken = processExitedSource.Token; + + /// + /// Set to true when the process termination is being requested so that it can be restarted within + /// the Hot Reload session (i.e. without restarting the root project). + /// + public bool IsRestarting => _isRestarting != 0; + + private volatile int _isRestarting; + private volatile bool _isDisposed; + + /// + /// Disposes the project. Can occur unexpectedly whenever the process exits. + /// Must only be called once per project. + /// + public void Dispose() + { + ObjectDisposedException.ThrowIf(_isDisposed, this); + + _isDisposed = true; + processExitedSource.Cancel(); + + Clients.Dispose(); + processTerminationSource.Dispose(); + processExitedSource.Dispose(); + } + + /// + /// Waits for the application process to start. + /// Ensures that the build has been complete and the build outputs are available. + /// Returns false if the process has exited before the connection was established. + /// + public async ValueTask WaitForProcessRunningAsync(CancellationToken cancellationToken) + { + using var processCommunicationCancellationSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, ProcessExitedCancellationToken); + + try + { + await Clients.WaitForConnectionEstablishedAsync(processCommunicationCancellationSource.Token); + return true; + } + catch (OperationCanceledException) when (ProcessExitedCancellationToken.IsCancellationRequested) + { + return false; + } + } + + /// + /// Terminates the process if it hasn't terminated yet. + /// + public Task TerminateAsync() + { + if (!_isDisposed) + { + processTerminationSource.Cancel(); + } + + return RunningProcess; + } + + /// + /// Marks the as restarting. + /// Subsequent process termination will be treated as a restart. + /// + /// True if the project hasn't been int restarting state prior the call. + public bool InitiateRestart() + => Interlocked.Exchange(ref _isRestarting, 1) == 0; + + /// + /// Terminates the process in preparation for a restart. + /// + public Task TerminateForRestartAsync() + { + InitiateRestart(); + return TerminateAsync(); + } + + public async Task CompleteApplyOperationAsync(Task applyTask) + { + try + { + await applyTask; + } + catch (OperationCanceledException) + { + // Do not report error. + } + catch (Exception e) + { + // Handle all exceptions. If one process is terminated or fails to apply changes + // it shouldn't prevent applying updates to other processes. + + Clients.ClientLogger.LogError("Failed to apply updates to process {Process}: {Exception}", ProcessId, e.ToString()); + } + } + } +} diff --git a/src/WatchPrototype/Watch/Process/WebServerProcessStateObserver.cs b/src/WatchPrototype/Watch/Process/WebServerProcessStateObserver.cs new file mode 100644 index 00000000000..684937ae8db --- /dev/null +++ b/src/WatchPrototype/Watch/Process/WebServerProcessStateObserver.cs @@ -0,0 +1,50 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.RegularExpressions; +using Microsoft.Build.Graph; + +namespace Microsoft.DotNet.Watch; + +/// +/// Observes the state of the web server by scanning its standard output for known patterns. +/// Notifies when the server starts listening. +/// +internal static partial class WebServerProcessStateObserver +{ + private static readonly Regex s_nowListeningRegex = GetNowListeningOnRegex(); + private static readonly Regex s_aspireDashboardUrlRegex = GetAspireDashboardUrlRegex(); + + [GeneratedRegex(@"Now listening on: (?.*)\s*$", RegexOptions.Compiled)] + private static partial Regex GetNowListeningOnRegex(); + + [GeneratedRegex(@"Login to the dashboard at (?.*)\s*$", RegexOptions.Compiled)] + private static partial Regex GetAspireDashboardUrlRegex(); + + public static void Observe(ProjectGraphNode serverProject, ProcessSpec serverProcessSpec, Action onServerListening) + { + // Workaround for Aspire dashboard launching: scan for "Login to the dashboard at " prefix in the output and use the URL. + // TODO: https://github.com/dotnet/sdk/issues/9038 + // Share launch profile processing logic as implemented in VS with dotnet-run and implement browser launching there. + bool isAspireHost = serverProject.GetCapabilities().Contains(AspireServiceFactory.AppHostProjectCapability); + + var _notified = false; + + serverProcessSpec.OnOutput += line => + { + if (_notified) + { + return; + } + + var match = (isAspireHost ? s_aspireDashboardUrlRegex : s_nowListeningRegex).Match(line.Content); + if (!match.Success) + { + return; + } + + _notified = true; + onServerListening(match.Groups["url"].Value); + }; + } +} diff --git a/src/WatchPrototype/Watch/Properties/AssemblyInfo.cs b/src/WatchPrototype/Watch/Properties/AssemblyInfo.cs new file mode 100644 index 00000000000..b54564628dc --- /dev/null +++ b/src/WatchPrototype/Watch/Properties/AssemblyInfo.cs @@ -0,0 +1,11 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("dotnet-watch, PublicKey = 0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] +[assembly: InternalsVisibleTo("dotnet-watch.Tests, PublicKey = 0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] +[assembly: InternalsVisibleTo("Microsoft.DotNet.HotReload.Test.Utilities, PublicKey = 0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] +[assembly: InternalsVisibleTo("Microsoft.DotNet.HotReload.Watch.Aspire, PublicKey = 0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] +[assembly: InternalsVisibleTo("Microsoft.DotNet.HotReload.Watch.Aspire.Tests, PublicKey = 0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] +[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2, PublicKey=0024000004800000940000000602000000240000525341310004000001000100c547cac37abd99c8db225ef2f6c8a3602f3b3606cc9891605d02baa56104f4cfc0734aa39b93bf7852f7d9266654753cc297e7d2edfe0bac1cdcf9f717241550e0a7b191195b7667bb4f64bcb8e2121380fd1d9d46ad2d92d2d15605093924cceaf74c4861eff62abf69b9291ed0a340e113be11e6a7d3113e92484cf7045cc7")] diff --git a/src/WatchPrototype/Watch/RuntimeDependencies.props b/src/WatchPrototype/Watch/RuntimeDependencies.props new file mode 100644 index 00000000000..7d4d40a9ab8 --- /dev/null +++ b/src/WatchPrototype/Watch/RuntimeDependencies.props @@ -0,0 +1,38 @@ + + + + None + true + false + TargetFramework;TargetFrameworks + hotreload\net6.0\Microsoft.AspNetCore.Watch.BrowserRefresh.dll + PreserveNewest + + + false + + + + None + true + false + TargetFramework=net10.0 + hotreload\net10.0\Microsoft.Extensions.DotNetDeltaApplier.dll + PreserveNewest + false + + + + None + true + false + TargetFramework=net6.0 + hotreload\net6.0\Microsoft.Extensions.DotNetDeltaApplier.dll + PreserveNewest + false + + + diff --git a/src/WatchPrototype/Watch/UI/BuildOutput.cs b/src/WatchPrototype/Watch/UI/BuildOutput.cs new file mode 100644 index 00000000000..ab32830bd6a --- /dev/null +++ b/src/WatchPrototype/Watch/UI/BuildOutput.cs @@ -0,0 +1,45 @@ +// 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.RegularExpressions; +using Microsoft.Extensions.Logging; + +namespace Microsoft.DotNet.Watch; + +internal static partial class BuildOutput +{ + private static readonly Regex s_buildDiagnosticRegex = GetBuildDiagnosticRegex(); + + [GeneratedRegex(@"[^:]+: (error|warning) [A-Za-z]+[0-9]+: .+")] + private static partial Regex GetBuildDiagnosticRegex(); + + public static void ReportBuildOutput(ILogger logger, IEnumerable buildOutput, bool success) + { + foreach (var (line, isError) in buildOutput) + { + if (isError) + { + logger.LogError(line); + } + else if (s_buildDiagnosticRegex.Match(line) is { Success: true } match) + { + if (match.Groups[1].Value == "error") + { + logger.LogError(line); + } + else + { + logger.LogWarning(line); + } + } + else if (success) + { + logger.LogDebug(line); + } + else + { + logger.LogInformation(line); + } + } + } +} diff --git a/src/WatchPrototype/Watch/UI/ConsoleInputReader.cs b/src/WatchPrototype/Watch/UI/ConsoleInputReader.cs new file mode 100644 index 00000000000..233d320765b --- /dev/null +++ b/src/WatchPrototype/Watch/UI/ConsoleInputReader.cs @@ -0,0 +1,98 @@ +// 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 sealed class ConsoleInputReader(IConsole console, LogLevel logLevel, bool suppressEmojis) + { + private readonly object _writeLock = new(); + + public async Task GetKeyAsync(string prompt, Func validateInput, CancellationToken cancellationToken) + { + if (logLevel > LogLevel.Information) + { + return ConsoleKey.Escape; + } + + var questionMark = suppressEmojis ? "?" : "❔"; + while (true) + { + // Start listening to the key before printing the prompt to avoid race condition + // in tests that wait for the prompt before sending the key press. + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + console.KeyPressed += KeyPressed; + + WriteLine($" {questionMark} {prompt}"); + + lock (_writeLock) + { + console.ForegroundColor = ConsoleColor.DarkGray; + console.Out.Write($" {questionMark} "); + console.ResetColor(); + } + + try + { + return await tcs.Task.WaitAsync(cancellationToken); + } + catch (ArgumentException) + { + // Prompt again for valid input + } + finally + { + console.KeyPressed -= KeyPressed; + } + + void KeyPressed(ConsoleKeyInfo key) + { + var keyDisplay = GetDisplayString(key); + if (validateInput(key)) + { + WriteLine(keyDisplay); + tcs.TrySetResult(key.Key); + } + else + { + WriteLine(keyDisplay, ConsoleColor.DarkRed); + tcs.TrySetException(new ArgumentException($"Invalid key '{keyDisplay}' entered.")); + } + } + + static string GetDisplayString(ConsoleKeyInfo key) + { + var keyDisplay = (key.Modifiers == ConsoleModifiers.None) ? key.KeyChar.ToString() : key.Key.ToString(); + + if (key.Modifiers.HasFlag(ConsoleModifiers.Alt)) + { + keyDisplay = "Alt+" + keyDisplay; + } + + if (key.Modifiers.HasFlag(ConsoleModifiers.Shift)) + { + keyDisplay = "Shift+" + keyDisplay; + } + + if (key.Modifiers.HasFlag(ConsoleModifiers.Control)) + { + keyDisplay = "Ctrl+" + keyDisplay; + } + + return keyDisplay; + } + } + + void WriteLine(string message, ConsoleColor color = ConsoleColor.DarkGray) + { + lock (_writeLock) + { + console.ForegroundColor = color; + console.Out.WriteLine(message); + console.ResetColor(); + } + } + } + } +} diff --git a/src/WatchPrototype/Watch/UI/ConsoleReporter.cs b/src/WatchPrototype/Watch/UI/ConsoleReporter.cs new file mode 100644 index 00000000000..60b156142b0 --- /dev/null +++ b/src/WatchPrototype/Watch/UI/ConsoleReporter.cs @@ -0,0 +1,65 @@ +// 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 +{ + /// + /// This API supports infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + internal sealed class ConsoleReporter(IConsole console, bool suppressEmojis) : IReporter, IProcessOutputReporter + { + public bool SuppressEmojis { get; } = suppressEmojis; + + private readonly Lock _writeLock = new(); + + bool IProcessOutputReporter.PrefixProcessOutput + => false; + + void IProcessOutputReporter.ReportOutput(OutputLine line) + { + lock (_writeLock) + { + (line.IsError ? console.Error : console.Out).WriteLine(line.Content); + } + } + + private void WriteLine(TextWriter writer, string message, ConsoleColor? color, Emoji emoji) + { + lock (_writeLock) + { + console.ForegroundColor = ConsoleColor.DarkGray; + writer.Write((SuppressEmojis ? Emoji.Default : emoji).GetLogMessagePrefix()); + console.ResetColor(); + + if (color.HasValue) + { + console.ForegroundColor = color.Value; + } + + writer.WriteLine(message); + + if (color.HasValue) + { + console.ResetColor(); + } + } + } + + public void Report(EventId id, Emoji emoji, LogLevel level, string message) + { + var color = level switch + { + LogLevel.Critical or LogLevel.Error => ConsoleColor.Red, + LogLevel.Warning => ConsoleColor.Yellow, + LogLevel.Information => (ConsoleColor?)null, + _ => ConsoleColor.DarkGray, + }; + + // Use stdout for error messages to preserve ordering with respect to other output. + WriteLine(console.Error, message, color, emoji); + } + } +} diff --git a/src/WatchPrototype/Watch/UI/IConsole.cs b/src/WatchPrototype/Watch/UI/IConsole.cs new file mode 100644 index 00000000000..54b1d8cfec7 --- /dev/null +++ b/src/WatchPrototype/Watch/UI/IConsole.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. + +namespace Microsoft.DotNet.Watch; + +/// +/// This API supports infrastructure and is not intended to be used +/// directly from your code. This API may change or be removed in future releases. +/// +internal interface IConsole +{ + event Action KeyPressed; + TextWriter Out { get; } + TextWriter Error { get; } + ConsoleColor ForegroundColor { get; set; } + void ResetColor(); + void Clear(); +} diff --git a/src/WatchPrototype/Watch/UI/IReporter.cs b/src/WatchPrototype/Watch/UI/IReporter.cs new file mode 100644 index 00000000000..b2421ef89fa --- /dev/null +++ b/src/WatchPrototype/Watch/UI/IReporter.cs @@ -0,0 +1,262 @@ +// 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.DotNet.HotReload; +using Microsoft.Extensions.Logging; + +namespace Microsoft.DotNet.Watch +{ + internal enum Emoji + { + Default = 0, + + Warning, + Error, + HotReload, + Watch, + Stop, + Restart, + Launch, + Wait, + Aspire, + Browser, + Agent, + Build, + Refresh, + LightBulb, + } + + internal static class Extensions + { + public static string ToDisplay(this Emoji emoji) + => emoji switch + { + Emoji.Default => ":", + Emoji.Warning => "⚠", + Emoji.Error => "❌", + Emoji.HotReload => "🔥", + Emoji.Watch => "⌚", + Emoji.Stop => "🛑", + Emoji.Restart => "🔄", + Emoji.Launch => "🚀", + Emoji.Wait => "⏳", + Emoji.Aspire => "⭐", + Emoji.Browser => "🌐", + Emoji.Agent => "🕵️", + Emoji.Build => "🔨", + Emoji.Refresh => "🔃", + Emoji.LightBulb => "💡", + _ => throw new InvalidOperationException() + }; + + public static string GetLogMessagePrefix(this Emoji emoji) + => $"dotnet watch {emoji.ToDisplay()} "; + + public static void Log(this ILogger logger, MessageDescriptor descriptor, params object?[] args) + { + logger.Log( + descriptor.Level, + descriptor.Id, + state: (descriptor, args), + exception: null, + formatter: static (state, _) => state.descriptor.GetMessage(state.args)); + } + } + + internal sealed class LoggerFactory(IReporter reporter, LogLevel level) : ILoggerFactory + { + private sealed class Logger(IReporter reporter, LogLevel level, string categoryName) : ILogger + { + public bool IsEnabled(LogLevel logLevel) + => logLevel >= level; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + if (!IsEnabled(logLevel)) + { + return; + } + + var (name, display) = LoggingUtilities.ParseCategoryName(categoryName); + var prefix = display != null ? $"[{display}] " : ""; + + var descriptor = eventId.Id != 0 ? MessageDescriptor.GetDescriptor(eventId) : default; + + var emoji = logLevel switch + { + _ when descriptor.Emoji != Emoji.Default => descriptor.Emoji, + LogLevel.Error => Emoji.Error, + LogLevel.Warning => Emoji.Warning, + _ when MessageDescriptor.ComponentEmojis.TryGetValue(name, out var componentEmoji) => componentEmoji, + _ => Emoji.Watch + }; + + reporter.Report(eventId, emoji, logLevel, prefix + formatter(state, exception)); + } + + public IDisposable? BeginScope(TState state) where TState : notnull + => throw new NotImplementedException(); + } + + public void Dispose() + { + } + + public ILogger CreateLogger(string categoryName) + => new Logger(reporter, level, categoryName); + + public void AddProvider(ILoggerProvider provider) + => throw new NotImplementedException(); + } + + internal readonly record struct MessageDescriptor(string Format, Emoji Emoji, LogLevel Level, EventId Id) + { + private static int s_id; + private static ImmutableDictionary s_descriptors = []; + + private static MessageDescriptor Create(string format, Emoji emoji, LogLevel level) + // reserve event id 0 for ad-hoc messages + => Create(new EventId(++s_id), format, emoji, level); + + private static MessageDescriptor Create(LogEvent logEvent, Emoji emoji) + => Create(logEvent.Id, logEvent.Message, emoji, logEvent.Level); + + private static MessageDescriptor Create(EventId id, string format, Emoji emoji, LogLevel level) + { + var descriptor = new MessageDescriptor(format, emoji, level, id.Id); + s_descriptors = s_descriptors.Add(id, descriptor); + return descriptor; + } + + public static MessageDescriptor GetDescriptor(EventId id) + => s_descriptors[id]; + + public string GetMessage(params object?[] args) + => Id.Id == 0 ? Format : string.Format(Format, args); + + public MessageDescriptor WithLevelWhen(LogLevel level, bool condition) + => condition && Level != level + ? this with + { + Level = level, + Emoji = level switch + { + LogLevel.Error or LogLevel.Critical => Emoji.Error, + LogLevel.Warning => Emoji.Warning, + _ => Emoji + } + } + : this; + + public static readonly ImmutableDictionary ComponentEmojis = ImmutableDictionary.Empty + .Add(DotNetWatchContext.DefaultLogComponentName, Emoji.Watch) + .Add(DotNetWatchContext.BuildLogComponentName, Emoji.Build) + .Add(HotReloadDotNetWatcher.ClientLogComponentName, Emoji.HotReload) + .Add(HotReloadDotNetWatcher.AgentLogComponentName, Emoji.Agent) + .Add(BrowserRefreshServer.ServerLogComponentName, Emoji.Refresh) + .Add(BrowserConnection.AgentLogComponentName, Emoji.Agent) + .Add(BrowserConnection.ServerLogComponentName, Emoji.Browser) + .Add(AspireServiceFactory.AspireLogComponentName, Emoji.Aspire); + + // predefined messages used for testing: + public static readonly MessageDescriptor HotReloadSessionStarting = Create("Hot reload session starting.", Emoji.HotReload, LogLevel.None); + public static readonly MessageDescriptor HotReloadSessionStarted = Create("Hot reload session started.", Emoji.HotReload, LogLevel.Debug); + public static readonly MessageDescriptor ProjectsRebuilt = Create("Projects rebuilt ({0})", Emoji.HotReload, LogLevel.Debug); + public static readonly MessageDescriptor ProjectsRestarted = Create("Projects restarted ({0})", Emoji.HotReload, LogLevel.Debug); + public static readonly MessageDescriptor ProjectDependenciesDeployed = Create("Project dependencies deployed ({0})", Emoji.HotReload, LogLevel.Debug); + public static readonly MessageDescriptor FixBuildError = Create("Fix the error to continue or press Ctrl+C to exit.", Emoji.Watch, LogLevel.Warning); + public static readonly MessageDescriptor WaitingForChanges = Create("Waiting for changes", Emoji.Watch, LogLevel.Information); + public static readonly MessageDescriptor LaunchedProcess = Create("Launched '{0}' with arguments '{1}': process id {2}", Emoji.Launch, LogLevel.Debug); + public static readonly MessageDescriptor ManagedCodeChangesApplied = Create("C# and Razor changes applied in {0}ms.", Emoji.HotReload, LogLevel.Information); + public static readonly MessageDescriptor StaticAssetsChangesApplied = Create("Static asset changes applied in {0}ms.", Emoji.HotReload, LogLevel.Information); + public static readonly MessageDescriptor SendingUpdateBatch = Create(LogEvents.SendingUpdateBatch, Emoji.HotReload); + public static readonly MessageDescriptor UpdateBatchCompleted = Create(LogEvents.UpdateBatchCompleted, Emoji.HotReload); + public static readonly MessageDescriptor UpdateBatchFailed = Create(LogEvents.UpdateBatchFailed, Emoji.HotReload); + public static readonly MessageDescriptor UpdateBatchCanceled = Create(LogEvents.UpdateBatchCanceled, Emoji.HotReload); + public static readonly MessageDescriptor UpdateBatchFailedWithError = Create(LogEvents.UpdateBatchFailedWithError, Emoji.HotReload); + public static readonly MessageDescriptor UpdateBatchExceptionStackTrace = Create(LogEvents.UpdateBatchExceptionStackTrace, Emoji.HotReload); + public static readonly MessageDescriptor Capabilities = Create(LogEvents.Capabilities, Emoji.HotReload); + public static readonly MessageDescriptor WaitingForFileChangeBeforeRestarting = Create("Waiting for a file to change before restarting ...", Emoji.Wait, LogLevel.Warning); + public static readonly MessageDescriptor WatchingWithHotReload = Create("Watching with Hot Reload.", Emoji.Watch, LogLevel.Debug); + public static readonly MessageDescriptor RestartInProgress = Create("Restart in progress.", Emoji.Restart, LogLevel.Information); + public static readonly MessageDescriptor RestartRequested = Create("Restart requested.", Emoji.Restart, LogLevel.Information); + public static readonly MessageDescriptor ShutdownRequested = Create("Shutdown requested. Press Ctrl+C again to force exit.", Emoji.Stop, LogLevel.Information); + public static readonly MessageDescriptor ApplyUpdate_Error = Create("{0}{1}", Emoji.Error, LogLevel.Error); + public static readonly MessageDescriptor ApplyUpdate_Warning = Create("{0}{1}", Emoji.Warning, LogLevel.Warning); + public static readonly MessageDescriptor ApplyUpdate_Verbose = Create("{0}{1}", Emoji.Default, LogLevel.Debug); + public static readonly MessageDescriptor ApplyUpdate_ChangingEntryPoint = Create("{0} Press \"Ctrl + R\" to restart.", Emoji.Warning, LogLevel.Warning); + public static readonly MessageDescriptor ApplyUpdate_FileContentDoesNotMatchBuiltSource = Create("{0} Expected if a source file is updated that is linked to project whose build is not up-to-date.", Emoji.Watch, LogLevel.Debug); + public static readonly MessageDescriptor ConfiguredToLaunchBrowser = Create("dotnet-watch is configured to launch a browser on ASP.NET Core application startup.", Emoji.Watch, LogLevel.Debug); + public static readonly MessageDescriptor ConfiguredToUseBrowserRefresh = Create("Using browser-refresh middleware", Emoji.Default, LogLevel.Debug); + public static readonly MessageDescriptor SkippingConfiguringBrowserRefresh_SuppressedViaEnvironmentVariable = Create("Skipping configuring browser-refresh middleware since its refresh server suppressed via environment variable {0}.", Emoji.Watch, LogLevel.Debug); + public static readonly MessageDescriptor SkippingConfiguringBrowserRefresh_TargetFrameworkNotSupported = Create("Skipping configuring browser-refresh middleware since the target framework version is not supported. For more information see 'https://aka.ms/dotnet/watch/unsupported-tfm'.", Emoji.Watch, LogLevel.Warning); + public static readonly MessageDescriptor UpdatingDiagnostics = Create(LogEvents.UpdatingDiagnostics, Emoji.Default); + public static readonly MessageDescriptor FailedToReceiveResponseFromConnectedBrowser = Create(LogEvents.FailedToReceiveResponseFromConnectedBrowser, Emoji.Default); + public static readonly MessageDescriptor NoBrowserConnected = Create(LogEvents.NoBrowserConnected, Emoji.Default); + public static readonly MessageDescriptor LaunchingBrowser = Create("Launching browser: {0} {1}", Emoji.Default, LogLevel.Debug); + public static readonly MessageDescriptor RefreshingBrowser = Create(LogEvents.RefreshingBrowser, Emoji.Default); + public static readonly MessageDescriptor ReloadingBrowser = Create(LogEvents.ReloadingBrowser, Emoji.Default); + public static readonly MessageDescriptor RefreshServerRunningAt = Create(LogEvents.RefreshServerRunningAt, Emoji.Default); + public static readonly MessageDescriptor ConnectedToRefreshServer = Create(LogEvents.ConnectedToRefreshServer, Emoji.Default); + public static readonly MessageDescriptor RestartingApplicationToApplyChanges = Create("Restarting application to apply changes ...", Emoji.Default, LogLevel.Information); + public static readonly MessageDescriptor RestartingApplication = Create("Restarting application ...", Emoji.Default, LogLevel.Information); + public static readonly MessageDescriptor IgnoringChangeInHiddenDirectory = Create("Ignoring change in hidden directory '{0}': {1} '{2}'", Emoji.Watch, LogLevel.Trace); + public static readonly MessageDescriptor IgnoringChangeInOutputDirectory = Create("Ignoring change in output directory: {0} '{1}'", Emoji.Watch, LogLevel.Trace); + public static readonly MessageDescriptor IgnoringChangeInExcludedFile = Create("Ignoring change in excluded file '{0}': {1}. Path matches {2} glob '{3}' set in '{4}'.", Emoji.Watch, LogLevel.Trace); + public static readonly MessageDescriptor FileAdditionTriggeredReEvaluation = Create("File addition triggered re-evaluation: '{0}'.", Emoji.Watch, LogLevel.Debug); + public static readonly MessageDescriptor ProjectChangeTriggeredReEvaluation = Create("Project change triggered re-evaluation: '{0}'.", Emoji.Watch, LogLevel.Debug); + public static readonly MessageDescriptor ReEvaluationCompleted = Create("Re-evaluation completed.", Emoji.Watch, LogLevel.Debug); + public static readonly MessageDescriptor NoCSharpChangesToApply = Create("No C# or Razor changes to apply.", Emoji.Watch, LogLevel.Information); + public static readonly MessageDescriptor Exited = Create("Exited", Emoji.Watch, LogLevel.Information); + public static readonly MessageDescriptor ExitedWithUnknownErrorCode = Create("Exited with unknown error code", Emoji.Error, LogLevel.Error); + public static readonly MessageDescriptor ExitedWithErrorCode = Create("Exited with error code {0}", Emoji.Error, LogLevel.Error); + public static readonly MessageDescriptor FailedToLaunchProcess = Create("Failed to launch '{0}' with arguments '{1}': {2}", Emoji.Error, LogLevel.Error); + public static readonly MessageDescriptor ApplicationFailed = Create("Application failed: {0}", Emoji.Error, LogLevel.Error); + public static readonly MessageDescriptor ProcessRunAndExited = Create("Process id {0} ran for {1}ms and exited with exit code {2}.", Emoji.Watch, LogLevel.Debug); + public static readonly MessageDescriptor WaitingForProcessToExitWithin = Create("Waiting for process {0} to exit within {1}s.", Emoji.Watch, LogLevel.Debug); + public static readonly MessageDescriptor WaitingForProcessToExit = Create("Waiting for process {0} to exit ({1}).", Emoji.Watch, LogLevel.Debug); + public static readonly MessageDescriptor FailedToKillProcess = Create("Failed to kill process {0}: {1}.", Emoji.Error, LogLevel.Error); + public static readonly MessageDescriptor TerminatingProcess = Create("Terminating process {0} ({1}).", Emoji.Watch, LogLevel.Debug); + public static readonly MessageDescriptor FailedToSendSignalToProcess = Create("Failed to send {0} signal to process {1}: {2}", Emoji.Warning, LogLevel.Warning); + public static readonly MessageDescriptor ErrorReadingProcessOutput = Create("Error reading {0} of process {1}: {2}", Emoji.Watch, LogLevel.Debug); + public static readonly MessageDescriptor SendingStaticAssetUpdateRequest = Create(LogEvents.SendingStaticAssetUpdateRequest, Emoji.Default); + public static readonly MessageDescriptor HotReloadCapabilities = Create("Hot reload capabilities: {0}.", Emoji.HotReload, LogLevel.Debug); + public static readonly MessageDescriptor HotReloadSuspended = Create("Hot reload suspended. To continue hot reload, press \"Ctrl + R\".", Emoji.HotReload, LogLevel.Information); + public static readonly MessageDescriptor UnableToApplyChanges = Create("Unable to apply changes due to compilation errors.", Emoji.HotReload, LogLevel.Information); + public static readonly MessageDescriptor RestartNeededToApplyChanges = Create("Restart is needed to apply the changes.", Emoji.HotReload, LogLevel.Information); + public static readonly MessageDescriptor HotReloadEnabled = Create("Hot reload enabled. For a list of supported edits, see https://aka.ms/dotnet/hot-reload.", Emoji.HotReload, LogLevel.Information); + public static readonly MessageDescriptor PressCtrlRToRestart = Create("Press Ctrl+R to restart.", Emoji.LightBulb, LogLevel.Information); + public static readonly MessageDescriptor ApplicationKind_BlazorHosted = Create("Application kind: BlazorHosted. '{0}' references BlazorWebAssembly project '{1}'.", Emoji.Default, LogLevel.Debug); + public static readonly MessageDescriptor ApplicationKind_BlazorWebAssembly = Create("Application kind: BlazorWebAssembly.", Emoji.Default, LogLevel.Debug); + public static readonly MessageDescriptor ApplicationKind_WebApplication = Create("Application kind: WebApplication.", Emoji.Default, LogLevel.Debug); + public static readonly MessageDescriptor ApplicationKind_Default = Create("Application kind: Default.", Emoji.Default, LogLevel.Debug); + public static readonly MessageDescriptor WatchingFilesForChanges = Create("Watching {0} file(s) for changes", Emoji.Watch, LogLevel.Debug); + public static readonly MessageDescriptor WatchingFilesForChanges_FilePath = Create("> {0}", Emoji.Watch, LogLevel.Trace); + public static readonly MessageDescriptor Building = Create("Building {0} ...", Emoji.Default, LogLevel.Information); + public static readonly MessageDescriptor BuildSucceeded = Create("Build succeeded: {0}", Emoji.Default, LogLevel.Information); + public static readonly MessageDescriptor BuildFailed = Create("Build failed: {0}", Emoji.Default, LogLevel.Information); + } + + internal interface IProcessOutputReporter + { + /// + /// If true, the output of the process will be prefixed with the project display name. + /// Used for testing. + /// + bool PrefixProcessOutput { get; } + + /// + /// Reports the output of a process that is being watched. + /// + /// + /// Not used to report output of dotnet-build processed launched by dotnet-watch to build or evaluate projects. + /// + void ReportOutput(OutputLine line); + } + + internal interface IReporter + { + void Report(EventId id, Emoji emoji, LogLevel level, string message); + } +} diff --git a/src/WatchPrototype/Watch/UI/OutputLine.cs b/src/WatchPrototype/Watch/UI/OutputLine.cs new file mode 100644 index 00000000000..be29eb65cfc --- /dev/null +++ b/src/WatchPrototype/Watch/UI/OutputLine.cs @@ -0,0 +1,6 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.DotNet.Watch; + +internal readonly record struct OutputLine(string Content, bool IsError); diff --git a/src/WatchPrototype/Watch/UI/PhysicalConsole.cs b/src/WatchPrototype/Watch/UI/PhysicalConsole.cs new file mode 100644 index 00000000000..46afed53a21 --- /dev/null +++ b/src/WatchPrototype/Watch/UI/PhysicalConsole.cs @@ -0,0 +1,113 @@ +// 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; + +namespace Microsoft.DotNet.Watch +{ + /// + /// This API supports infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + internal sealed class PhysicalConsole : IConsole + { + public const char CtrlC = '\x03'; + public const char CtrlR = '\x12'; + + public event Action? KeyPressed; + + public PhysicalConsole(TestFlags testFlags) + { + Console.OutputEncoding = Encoding.UTF8; + _ = testFlags.HasFlag(TestFlags.ReadKeyFromStdin) ? ListenToStandardInputAsync() : ListenToConsoleKeyPressAsync(); + } + + private async Task ListenToStandardInputAsync() + { + using var stream = Console.OpenStandardInput(); + var buffer = new byte[1]; + + while (true) + { + var bytesRead = await stream.ReadAsync(buffer, CancellationToken.None); + if (bytesRead != 1) + { + break; + } + + var c = (char)buffer[0]; + + // emulate propagation of Ctrl+C/SIGTERM to child processes + if (c == CtrlC) + { + Console.WriteLine("Received CTRL+C key"); + + foreach (var processId in ProcessRunner.GetRunningApplicationProcesses()) + { + string? error; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + Console.WriteLine($"Sending Ctrl+C to {processId}"); + error = ProcessUtilities.SendWindowsCtrlCEvent(processId); + } + else + { + Console.WriteLine($"Sending SIGTERM to {processId}"); + error = ProcessUtilities.SendPosixSignal(processId, ProcessUtilities.SIGTERM); + } + + if (error != null) + { + throw new InvalidOperationException(error); + } + } + } + + // handle all input keys that watcher might consume: + var key = c switch + { + CtrlC => new ConsoleKeyInfo('C', ConsoleKey.C, shift: false, alt: false, control: true), + CtrlR => new ConsoleKeyInfo('R', ConsoleKey.R, shift: false, alt: false, control: true), + >= 'a' and <= 'z' => new ConsoleKeyInfo(c, ConsoleKey.A + (c - 'a'), shift: false, alt: false, control: false), + >= 'A' and <= 'Z' => new ConsoleKeyInfo(c, ConsoleKey.A + (c - 'A'), shift: true, alt: false, control: false), + _ => default + }; + + if (key.Key != ConsoleKey.None) + { + KeyPressed?.Invoke(key); + } + } + } + + private Task ListenToConsoleKeyPressAsync() + { + Console.CancelKeyPress += (s, e) => + { + e.Cancel = true; + KeyPressed?.Invoke(new ConsoleKeyInfo(CtrlC, ConsoleKey.C, shift: false, alt: false, control: true)); + }; + + return Task.Factory.StartNew(() => + { + while (true) + { + var key = Console.ReadKey(intercept: true); + KeyPressed?.Invoke(key); + } + }, TaskCreationOptions.LongRunning); + } + + public TextWriter Error => Console.Error; + public TextWriter Out => Console.Out; + + public ConsoleColor ForegroundColor + { + get => Console.ForegroundColor; + set => Console.ForegroundColor = value; + } + + public void ResetColor() => Console.ResetColor(); + public void Clear() => Console.Clear(); + } +} diff --git a/src/WatchPrototype/Watch/UI/RestartPrompt.cs b/src/WatchPrototype/Watch/UI/RestartPrompt.cs new file mode 100644 index 00000000000..412f4b000cc --- /dev/null +++ b/src/WatchPrototype/Watch/UI/RestartPrompt.cs @@ -0,0 +1,49 @@ +// 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 sealed class RestartPrompt(ILogger logger, ConsoleInputReader requester, bool? noPrompt) + { + public bool? AutoRestartPreference { get; private set; } = noPrompt; + + public async ValueTask WaitForRestartConfirmationAsync(string question, CancellationToken cancellationToken) + { + if (AutoRestartPreference.HasValue) + { + logger.LogInformation("Restarting"); + return AutoRestartPreference.Value; + } + + var key = await requester.GetKeyAsync( + $"{question} Yes (y) / No (n) / Always (a) / Never (v)", + AcceptKey, + cancellationToken); + + switch (key) + { + case ConsoleKey.Escape: + case ConsoleKey.Y: + return true; + + case ConsoleKey.N: + return false; + + case ConsoleKey.A: + AutoRestartPreference = true; + return true; + + case ConsoleKey.V: + AutoRestartPreference = false; + return false; + } + + throw new InvalidOperationException(); + + static bool AcceptKey(ConsoleKeyInfo info) + => info is { Key: ConsoleKey.Y or ConsoleKey.N or ConsoleKey.A or ConsoleKey.V, Modifiers: ConsoleModifiers.None }; + } + } +} diff --git a/src/WatchPrototype/Watch/UI/ShutdownHandler.cs b/src/WatchPrototype/Watch/UI/ShutdownHandler.cs new file mode 100644 index 00000000000..70cc2355fe4 --- /dev/null +++ b/src/WatchPrototype/Watch/UI/ShutdownHandler.cs @@ -0,0 +1,44 @@ +// 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 sealed class ShutdownHandler : IDisposable +{ + private readonly CancellationTokenSource _cancellationSource = new(); + public CancellationToken CancellationToken { get; } + + private volatile bool _disposed; + + public ShutdownHandler(IConsole console, ILogger logger) + { + CancellationToken = _cancellationSource.Token; + + console.KeyPressed += key => + { + if (!_disposed && key.Modifiers.HasFlag(ConsoleModifiers.Control) && key.Key == ConsoleKey.C) + { + // if we already canceled, we force immediate shutdown: + var forceShutdown = _cancellationSource.IsCancellationRequested; + + if (!forceShutdown) + { + logger.Log(MessageDescriptor.ShutdownRequested); + _cancellationSource.Cancel(); + } + else + { + Environment.Exit(0); + } + } + }; + } + + public void Dispose() + { + _disposed = true; + _cancellationSource.Dispose(); + } +} diff --git a/src/WatchPrototype/Watch/Utilities/CommandLineUtilities.cs b/src/WatchPrototype/Watch/Utilities/CommandLineUtilities.cs new file mode 100644 index 00000000000..587087f4936 --- /dev/null +++ b/src/WatchPrototype/Watch/Utilities/CommandLineUtilities.cs @@ -0,0 +1,111 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +// Copied from dotnet/runtime/src/libraries/System.Private.CoreLib/src/System/PasteArguments.cs +namespace Microsoft.DotNet.Watch; + +internal static class CommandLineUtilities +{ + public static string JoinArguments(IEnumerable arguments) + { + var builder = new StringBuilder(); + AppendArguments(builder, arguments); + return builder.ToString(); + } + + public static void AppendArguments(StringBuilder builder, IEnumerable arguments) + { + foreach (var arg in arguments) + { + AppendArgument(builder, arg); + } + } + + private static void AppendArgument(StringBuilder stringBuilder, string argument) + { + if (stringBuilder.Length != 0) + { + stringBuilder.Append(' '); + } + + // Parsing rules for non-argv[0] arguments: + // - Backslash is a normal character except followed by a quote. + // - 2N backslashes followed by a quote ==> N literal backslashes followed by unescaped quote + // - 2N+1 backslashes followed by a quote ==> N literal backslashes followed by a literal quote + // - Parsing stops at first whitespace outside of quoted region. + // - (post 2008 rule): A closing quote followed by another quote ==> literal quote, and parsing remains in quoting mode. + if (argument.Length != 0 && ContainsNoWhitespaceOrQuotes(argument)) + { + // Simple case - no quoting or changes needed. + stringBuilder.Append(argument); + } + else + { + stringBuilder.Append(Quote); + int idx = 0; + while (idx < argument.Length) + { + char c = argument[idx++]; + if (c == Backslash) + { + int numBackSlash = 1; + while (idx < argument.Length && argument[idx] == Backslash) + { + idx++; + numBackSlash++; + } + + if (idx == argument.Length) + { + // We'll emit an end quote after this so must double the number of backslashes. + stringBuilder.Append(Backslash, numBackSlash * 2); + } + else if (argument[idx] == Quote) + { + // Backslashes will be followed by a quote. Must double the number of backslashes. + stringBuilder.Append(Backslash, numBackSlash * 2 + 1); + stringBuilder.Append(Quote); + idx++; + } + else + { + // Backslash will not be followed by a quote, so emit as normal characters. + stringBuilder.Append(Backslash, numBackSlash); + } + + continue; + } + + if (c == Quote) + { + // Escape the quote so it appears as a literal. This also guarantees that we won't end up generating a closing quote followed + // by another quote (which parses differently pre-2008 vs. post-2008.) + stringBuilder.Append(Backslash); + stringBuilder.Append(Quote); + continue; + } + + stringBuilder.Append(c); + } + + stringBuilder.Append(Quote); + } + } + + private static bool ContainsNoWhitespaceOrQuotes(string s) + { + for (int i = 0; i < s.Length; i++) + { + char c = s[i]; + if (char.IsWhiteSpace(c) || c == Quote) + { + return false; + } + } + + return true; + } + + private const char Quote = '\"'; + private const char Backslash = '\\'; +} diff --git a/src/WatchPrototype/Watch/Utilities/Disposables.cs b/src/WatchPrototype/Watch/Utilities/Disposables.cs new file mode 100644 index 00000000000..7aa9de71941 --- /dev/null +++ b/src/WatchPrototype/Watch/Utilities/Disposables.cs @@ -0,0 +1,17 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.DotNet.Watch; + +internal readonly record struct Disposables(List disposables) : IDisposable +{ + public List Items => disposables; + + public void Dispose() + { + foreach (var disposable in disposables) + { + disposable.Dispose(); + } + } +} diff --git a/src/WatchPrototype/Watch/Utilities/PathUtilities.cs b/src/WatchPrototype/Watch/Utilities/PathUtilities.cs new file mode 100644 index 00000000000..d8395337dc8 --- /dev/null +++ b/src/WatchPrototype/Watch/Utilities/PathUtilities.cs @@ -0,0 +1,60 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.DotNet.Watch; + +internal static class PathUtilities +{ + public static readonly IEqualityComparer OSSpecificPathComparer = Path.DirectorySeparatorChar == '\\' ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; + public static readonly StringComparison OSSpecificPathComparison = Path.DirectorySeparatorChar == '\\' ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + + public static string ExecutableExtension + => RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? ".exe" : ""; + + public static string EnsureTrailingSlash(string path) + => (path is [.., var last] && last != Path.DirectorySeparatorChar) ? path + Path.DirectorySeparatorChar : path; + + public static string NormalizeDirectorySeparators(string path) + => path.Replace('\\', Path.DirectorySeparatorChar).Replace('/', Path.DirectorySeparatorChar); + + public static bool ContainsPath(IReadOnlySet directories, string fullPath) + { + if (directories.Count == 0) + { + return false; + } + + fullPath = Path.TrimEndingDirectorySeparator(fullPath); + + while (true) + { + if (directories.Contains(fullPath)) + { + return true; + } + + var containingDir = Path.GetDirectoryName(fullPath); + if (containingDir == null) + { + return false; + } + + fullPath = containingDir; + } + } + + public static IEnumerable GetContainingDirectories(string path) + { + while (true) + { + var containingDir = Path.GetDirectoryName(path); + if (containingDir == null) + { + yield break; + } + + yield return containingDir; + path = containingDir; + } + } +} diff --git a/src/WatchPrototype/Watch/Utilities/ProcessUtilities.cs b/src/WatchPrototype/Watch/Utilities/ProcessUtilities.cs new file mode 100644 index 00000000000..4040e186f0c --- /dev/null +++ b/src/WatchPrototype/Watch/Utilities/ProcessUtilities.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 System; +using System.Runtime.InteropServices; +using System.Diagnostics; + +namespace Microsoft.DotNet.Watch; + +internal static class ProcessUtilities +{ + public const int SIGKILL = 9; + public const int SIGTERM = 15; + + public static string? SendWindowsCtrlCEvent(int processId) + { + const uint CTRL_C_EVENT = 0; + + // Doc: + // "The process identifier of the new process is also the process group identifier of a new process group. + // + // The process group includes all processes that are descendants of the root process. + // Only those processes in the group that share the same console as the calling process receive the signal. + // In other words, if a process in the group creates a new console, that process does not receive the signal, + // nor do its descendants. + // + // If this parameter is zero, the signal is generated in all processes that share the console of the calling process." + return GenerateConsoleCtrlEvent(CTRL_C_EVENT, (uint)processId) ? null : GetLastPInvokeErrorMessage(); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + static extern bool GenerateConsoleCtrlEvent(uint dwCtrlEvent, uint dwProcessGroupId); + } + + public static string? SendPosixSignal(int processId, int signal) + { + return sys_kill(processId, signal) == 0 ? null : GetLastPInvokeErrorMessage(); + + [DllImport("libc", SetLastError = true, EntryPoint = "kill")] + static extern int sys_kill(int pid, int sig); + } + + private static string GetLastPInvokeErrorMessage() + { + var error = Marshal.GetLastPInvokeError(); +#if NET10_0_OR_GREATER + return $"{Marshal.GetPInvokeErrorMessage(error)} (code {error})"; +#else + return $"error code {error}"; +#endif + } +} diff --git a/src/WatchPrototype/Watch/Utilities/Versions.cs b/src/WatchPrototype/Watch/Utilities/Versions.cs new file mode 100644 index 00000000000..3f32b1b7d9a --- /dev/null +++ b/src/WatchPrototype/Watch/Utilities/Versions.cs @@ -0,0 +1,10 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.DotNet.Watch; + +internal static class Versions +{ + public static readonly Version Version3_1 = new(3, 1); + public static readonly Version Version6_0 = new(6, 0); +} diff --git a/src/WatchPrototype/Web.Middleware/.editorconfig b/src/WatchPrototype/Web.Middleware/.editorconfig new file mode 100644 index 00000000000..fb89bbbf5f8 --- /dev/null +++ b/src/WatchPrototype/Web.Middleware/.editorconfig @@ -0,0 +1,9 @@ +[*.cs] + +# IDE0240: Remove redundant nullable directive +# The directive needs to be included since all sources in a source package are considered generated code +# when referenced from a project via package reference. +dotnet_diagnostic.IDE0240.severity = none + +[*.js] +indent_size = 2 \ No newline at end of file diff --git a/src/WatchPrototype/Web.Middleware/ApplicationPaths.cs b/src/WatchPrototype/Web.Middleware/ApplicationPaths.cs new file mode 100644 index 00000000000..6d784e60e11 --- /dev/null +++ b/src/WatchPrototype/Web.Middleware/ApplicationPaths.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 Microsoft.AspNetCore.Http; + +namespace Microsoft.AspNetCore.Watch.BrowserRefresh +{ + internal static class ApplicationPaths + { + /// + /// The PathString all listening URLs must be registered in + /// + /// /_framework/ + public static PathString FrameworkRoot { get; } = "/_framework"; + + /// + /// An endpoint that responds with cache-clearing headers. See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Clear-Site-Data#directives. + /// + /// /_framework/clear-browser-cache + public static PathString ClearSiteData { get; } = FrameworkRoot + "/clear-browser-cache"; + + /// + /// Returns a JS file that handles browser refresh and showing notifications. + /// + /// /_framework/aspnetcore-browser-refresh.js + public static PathString BrowserRefreshJS { get; } = FrameworkRoot + "/aspnetcore-browser-refresh.js"; + + /// + /// Hosts a middleware that can cache deltas sent by dotnet-watch. + /// + /// /_framework/blazor-hotreload + public static PathString BlazorHotReloadMiddleware { get; } = FrameworkRoot + "/blazor-hotreload"; + + /// + /// Returns a JS file imported by BlazorWebAssembly as part of it's initialization. Contains + /// scripts to apply deltas on app start. + /// + /// /_framework/blazor-hotreload.js + public static PathString BlazorHotReloadJS { get; } = FrameworkRoot + "/blazor-hotreload.js"; + } +} diff --git a/src/WatchPrototype/Web.Middleware/BlazorHotReload.js b/src/WatchPrototype/Web.Middleware/BlazorHotReload.js new file mode 100644 index 00000000000..87a203450f0 --- /dev/null +++ b/src/WatchPrototype/Web.Middleware/BlazorHotReload.js @@ -0,0 +1,23 @@ +// Used by older versions of Microsoft.AspNetCore.Components.WebAssembly. +// For back compat only to support WASM packages older than the SDK. + +export function receiveHotReload() { + return BINDING.js_to_mono_obj(new Promise((resolve) => receiveHotReloadAsync().then(resolve(0)))); +} + +export async function receiveHotReloadAsync() { + const response = await fetch('/_framework/blazor-hotreload'); + if (response.status === 200) { + const updates = await response.json(); + if (updates) { + try { + updates.forEach(u => { + u.deltas.forEach(d => window.Blazor._internal.applyHotReload(d.moduleId, d.metadataDelta, d.ilDelta, d.pdbDelta, d.updatedTypes)); + }) + } catch (error) { + console.warn(error); + return; + } + } + } +} diff --git a/src/WatchPrototype/Web.Middleware/BlazorWasmHotReloadMiddleware.cs b/src/WatchPrototype/Web.Middleware/BlazorWasmHotReloadMiddleware.cs new file mode 100644 index 00000000000..036ae918f03 --- /dev/null +++ b/src/WatchPrototype/Web.Middleware/BlazorWasmHotReloadMiddleware.cs @@ -0,0 +1,103 @@ +// 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; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Watch.BrowserRefresh +{ + /// + /// A middleware that manages receiving and sending deltas from a BlazorWebAssembly app. + /// This assembly is shared between Visual Studio and dotnet-watch. By putting some of the complexity + /// in here, we can avoid duplicating work in watch and VS. + /// + /// Mapped to . + /// + internal sealed class BlazorWasmHotReloadMiddleware + { + internal sealed class Update + { + public int Id { get; set; } + public Delta[] Deltas { get; set; } = default!; + } + + internal sealed class Delta + { + public string ModuleId { get; set; } = default!; + public string MetadataDelta { get; set; } = default!; + public string ILDelta { get; set; } = default!; + public string PdbDelta { get; set; } = default!; + public int[] UpdatedTypes { get; set; } = default!; + } + + private static readonly JsonSerializerOptions s_jsonSerializerOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + public BlazorWasmHotReloadMiddleware(RequestDelegate next, ILogger logger) + { + logger.LogDebug("Middleware loaded"); + } + + internal List Updates { get; } = []; + + public Task InvokeAsync(HttpContext context) + { + // Multiple instances of the BlazorWebAssembly app could be running (multiple tabs or multiple browsers). + // We want to avoid serialize reads and writes between then + lock (Updates) + { + if (HttpMethods.IsGet(context.Request.Method)) + { + return OnGet(context); + } + else if (HttpMethods.IsPost(context.Request.Method)) + { + return OnPost(context); + } + else + { + context.Response.StatusCode = StatusCodes.Status405MethodNotAllowed; + return Task.CompletedTask; + } + } + + // Don't call next(). This middleware is terminal. + } + + private async Task OnGet(HttpContext context) + { + if (Updates.Count == 0) + { + context.Response.StatusCode = StatusCodes.Status204NoContent; + return; + } + + await JsonSerializer.SerializeAsync(context.Response.Body, Updates, s_jsonSerializerOptions); + } + + private async Task OnPost(HttpContext context) + { + var update = await JsonSerializer.DeserializeAsync(context.Request.Body, s_jsonSerializerOptions); + if (update == null) + { + context.Response.StatusCode = StatusCodes.Status400BadRequest; + return; + } + + // It's possible that multiple instances of the BlazorWasm are simultaneously executing and could be posting the same deltas + // We'll use the sequence id to ensure that we're not recording duplicate entries. Replaying duplicated values would cause + // ApplyDelta to fail. + if (Updates is [] || Updates[^1].Id < update.Id) + { + Updates.Add(update); + } + } + } +} diff --git a/src/WatchPrototype/Web.Middleware/BrowserRefreshMiddleware.cs b/src/WatchPrototype/Web.Middleware/BrowserRefreshMiddleware.cs new file mode 100644 index 00000000000..fd215c4c9dc --- /dev/null +++ b/src/WatchPrototype/Web.Middleware/BrowserRefreshMiddleware.cs @@ -0,0 +1,254 @@ +// 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.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Primitives; +using Microsoft.Net.Http.Headers; + +namespace Microsoft.AspNetCore.Watch.BrowserRefresh +{ + public sealed class BrowserRefreshMiddleware + { + private static readonly MediaTypeHeaderValue s_textHtmlMediaType = new("text/html"); + private static readonly MediaTypeHeaderValue s_applicationJsonMediaType = new("application/json"); + private readonly RequestDelegate _next; + private readonly ILogger _logger; + private string? _dotnetModifiableAssemblies = GetNonEmptyEnvironmentVariableValue("DOTNET_MODIFIABLE_ASSEMBLIES"); + private string? _aspnetcoreBrowserTools = GetNonEmptyEnvironmentVariableValue("__ASPNETCORE_BROWSER_TOOLS"); + + public BrowserRefreshMiddleware(RequestDelegate next, ILogger logger) + { + _next = next; + _logger = logger; + + logger.LogDebug("Middleware loaded: DOTNET_MODIFIABLE_ASSEMBLIES={ModifiableAssemblies}, __ASPNETCORE_BROWSER_TOOLS={BrowserTools}", _dotnetModifiableAssemblies, _aspnetcoreBrowserTools); + } + + private static string? GetNonEmptyEnvironmentVariableValue(string name) + => Environment.GetEnvironmentVariable(name) is { Length: > 0 } value ? value : null; + + public async Task InvokeAsync(HttpContext context) + { + if (IsWebAssemblyBootRequest(context)) + { + AttachWebAssemblyHeaders(context); + await _next(context); + } + else if (IsBrowserDocumentRequest(context)) + { + // Use a custom StreamWrapper to rewrite output on Write/WriteAsync + using var responseStreamWrapper = new ResponseStreamWrapper(context, _logger); + var originalBodyFeature = context.Features.Get(); + context.Features.Set(new StreamResponseBodyFeature(responseStreamWrapper)); + + try + { + await _next(context); + + // We complete the wrapper stream to ensure that any intermediate buffers + // get fully flushed to the response stream. This is also required to + // reliably determine whether script injection was performed. + await responseStreamWrapper.CompleteAsync(); + } + finally + { + context.Features.Set(originalBodyFeature); + } + + if (responseStreamWrapper.IsHtmlResponse) + { + if (responseStreamWrapper.ScriptInjectionPerformed) + { + Log.BrowserConfiguredForRefreshes(_logger); + } + else if (context.Response.Headers.TryGetValue(HeaderNames.ContentEncoding, out var contentEncodings)) + { + Log.ResponseCompressionDetected(_logger, contentEncodings); + } + else + { + Log.FailedToConfiguredForRefreshes(_logger); + } + } + } + else + { + await _next(context); + } + } + + private void AttachWebAssemblyHeaders(HttpContext context) + { + context.Response.OnStarting(() => + { + if (!context.Response.Headers.ContainsKey("DOTNET-MODIFIABLE-ASSEMBLIES")) + { + if (_dotnetModifiableAssemblies != null) + { + context.Response.Headers.Append("DOTNET-MODIFIABLE-ASSEMBLIES", _dotnetModifiableAssemblies); + } + else + { + _logger.LogDebug("DOTNET_MODIFIABLE_ASSEMBLIES environment variable is not set, likely because hot reload is not enabled. The browser refresh feature may not work as expected."); + } + } + else + { + _logger.LogDebug("DOTNET-MODIFIABLE-ASSEMBLIES header is already set."); + } + + if (!context.Response.Headers.ContainsKey("ASPNETCORE-BROWSER-TOOLS")) + { + if (_aspnetcoreBrowserTools != null) + { + context.Response.Headers.Append("ASPNETCORE-BROWSER-TOOLS", _aspnetcoreBrowserTools); + } + else + { + _logger.LogDebug("__ASPNETCORE_BROWSER_TOOLS environment variable is not set. The browser refresh feature may not work as expected."); + } + } + else + { + _logger.LogDebug("ASPNETCORE-BROWSER-TOOLS header is already set."); + } + + return Task.CompletedTask; + }); + } + + internal static bool IsWebAssemblyBootRequest(HttpContext context) + { + var request = context.Request; + if (!HttpMethods.IsGet(request.Method)) + { + return false; + } + + if (request.Headers.TryGetValue("Sec-Fetch-Dest", out var values) && + !StringValues.IsNullOrEmpty(values) && + !string.Equals(values[0], "empty", StringComparison.OrdinalIgnoreCase)) + { + // See https://github.com/dotnet/aspnetcore/issues/37326. + // Only inject scripts that are destined for a browser page. + return false; + } + + if (!request.Path.HasValue || + !string.Equals(Path.GetFileName(request.Path.Value), "blazor.boot.json", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var typedHeaders = request.GetTypedHeaders(); + if (typedHeaders.Accept is not IList acceptHeaders) + { + return false; + } + + for (var i = 0; i < acceptHeaders.Count; i++) + { + if (acceptHeaders[i].MatchesAllTypes || acceptHeaders[i].IsSubsetOf(s_applicationJsonMediaType)) + { + return true; + } + } + + return false; + } + + internal static bool IsBrowserDocumentRequest(HttpContext context) + { + var request = context.Request; + if (!HttpMethods.IsGet(request.Method) && !HttpMethods.IsPost(request.Method)) + { + return false; + } + + if (request.Headers.TryGetValue("Sec-Fetch-Dest", out var values) && + !StringValues.IsNullOrEmpty(values) && + !string.Equals(values[0], "document", StringComparison.OrdinalIgnoreCase) && + !IsProgressivelyEnhancedNavigation(context.Request)) + { + // See https://github.com/dotnet/aspnetcore/issues/37326. + // Only inject scripts that are destined for a browser page. + return false; + } + + var typedHeaders = request.GetTypedHeaders(); + if (typedHeaders.Accept is not IList acceptHeaders) + { + return false; + } + + for (var i = 0; i < acceptHeaders.Count; i++) + { + if (acceptHeaders[i].IsSubsetOf(s_textHtmlMediaType)) + { + return true; + } + } + + return false; + } + + private static bool IsProgressivelyEnhancedNavigation(HttpRequest request) + { + // This is an exact copy from https://github.com/dotnet/aspnetcore/blob/bb2d778dc66aa998ea8e26db0e98e7e01423ff78/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Streaming.cs#L327-L332 + // For enhanced nav, the Blazor JS code controls the "accept" header precisely, so we can be very specific about the format + var accept = request.Headers.Accept; + return accept.Count == 1 && string.Equals(accept[0]!, "text/html; blazor-enhanced-nav=on", StringComparison.Ordinal); + } + + internal void Test_SetEnvironment(string dotnetModifiableAssemblies, string aspnetcoreBrowserTools) + { + _dotnetModifiableAssemblies = dotnetModifiableAssemblies; + _aspnetcoreBrowserTools = aspnetcoreBrowserTools; + } + + internal static class Log + { + private static readonly Action _setupResponseForBrowserRefresh = LoggerMessage.Define( + LogLevel.Debug, + new EventId(1, "SetUpResponseForBrowserRefresh"), + "Response markup is scheduled to include browser refresh script injection."); + + private static readonly Action _browserConfiguredForRefreshes = LoggerMessage.Define( + LogLevel.Debug, + new EventId(2, "BrowserConfiguredForRefreshes"), + "Response markup was updated to include browser refresh script injection."); + + private static readonly Action _failedToConfigureForRefreshes = LoggerMessage.Define( + LogLevel.Warning, + new EventId(3, "FailedToConfiguredForRefreshes"), + "Unable to configure browser refresh script injection on the response. " + + $"Consider manually adding '{ScriptInjectingStream.InjectedScript}' to the body of the page."); + + private static readonly Action _responseCompressionDetected = LoggerMessage.Define( + LogLevel.Warning, + new EventId(4, "ResponseCompressionDetected"), + "Unable to configure browser refresh script injection on the response. " + + $"This may have been caused by the response's {HeaderNames.ContentEncoding}: '{{encoding}}'. " + + "Consider disabling response compression."); + + private static readonly Action _scriptInjectionSkipped = LoggerMessage.Define( + LogLevel.Debug, + new EventId(6, "ScriptInjectionSkipped"), + "Browser refresh script injection skipped. Status code: {StatusCode}, Content type: {ContentType}"); + + public static void SetupResponseForBrowserRefresh(ILogger logger) => _setupResponseForBrowserRefresh(logger, null); + public static void BrowserConfiguredForRefreshes(ILogger logger) => _browserConfiguredForRefreshes(logger, null); + public static void FailedToConfiguredForRefreshes(ILogger logger) => _failedToConfigureForRefreshes(logger, null); + public static void ResponseCompressionDetected(ILogger logger, StringValues encoding) => _responseCompressionDetected(logger, encoding, null); + public static void ScriptInjectionSkipped(ILogger logger, int statusCode, string? contentType) => _scriptInjectionSkipped(logger, statusCode, contentType, null); + } + } +} diff --git a/src/WatchPrototype/Web.Middleware/BrowserScriptMiddleware.cs b/src/WatchPrototype/Web.Middleware/BrowserScriptMiddleware.cs new file mode 100644 index 00000000000..bade75e4fed --- /dev/null +++ b/src/WatchPrototype/Web.Middleware/BrowserScriptMiddleware.cs @@ -0,0 +1,78 @@ +// 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.Globalization; +using System.IO; +using System.Text; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Watch.BrowserRefresh +{ + /// + /// Responds with the contents of WebSocketScriptInjection.js with the stub WebSocket url replaced by the + /// one specified by the launching app. + /// + public sealed class BrowserScriptMiddleware + { + private readonly PathString _scriptPath; + private readonly ReadOnlyMemory _scriptBytes; + private readonly ILogger _logger; + private readonly string _contentLength; + + public BrowserScriptMiddleware(RequestDelegate next, PathString scriptPath, ReadOnlyMemory scriptBytes, ILogger logger) + { + _scriptPath = scriptPath; + _scriptBytes = scriptBytes; + _logger = logger; + _contentLength = _scriptBytes.Length.ToString(CultureInfo.InvariantCulture); + + logger.LogDebug("Middleware loaded. Script {scriptPath} ({size} B).", scriptPath, _contentLength); + } + + public async Task InvokeAsync(HttpContext context) + { + context.Response.Headers["Cache-Control"] = "no-store"; + context.Response.Headers["Content-Length"] = _contentLength; + context.Response.Headers["Content-Type"] = "application/javascript; charset=utf-8"; + + await context.Response.Body.WriteAsync(_scriptBytes, context.RequestAborted); + + _logger.LogDebug("Script injected: {scriptPath}", _scriptPath); + } + + // for backwards compat only + internal static ReadOnlyMemory GetBlazorHotReloadJS() + { + var jsFileName = "BlazorHotReload.js"; + using var stream = new MemoryStream(); + var manifestStream = typeof(BrowserScriptMiddleware).Assembly.GetManifestResourceStream(jsFileName)!; + manifestStream.CopyTo(stream); + + return stream.ToArray(); + } + + internal static ReadOnlyMemory GetBrowserRefreshJS() + { + var endpoint = Environment.GetEnvironmentVariable("ASPNETCORE_AUTO_RELOAD_WS_ENDPOINT")!; + var serverKey = Environment.GetEnvironmentVariable("ASPNETCORE_AUTO_RELOAD_WS_KEY") ?? string.Empty; + + return GetWebSocketClientJavaScript(endpoint, serverKey); + } + + internal static ReadOnlyMemory GetWebSocketClientJavaScript(string hostString, string serverKey) + { + var jsFileName = "WebSocketScriptInjection.js"; + using var reader = new StreamReader(typeof(BrowserScriptMiddleware).Assembly.GetManifestResourceStream(jsFileName)!); + var script = reader.ReadToEnd() + .Replace("{{hostString}}", hostString) + .Replace("{{ServerKey}}", serverKey); + + return Encoding.UTF8.GetBytes(script); + } + } +} diff --git a/src/WatchPrototype/Web.Middleware/HostingStartup.cs b/src/WatchPrototype/Web.Middleware/HostingStartup.cs new file mode 100644 index 00000000000..d9354ebdac3 --- /dev/null +++ b/src/WatchPrototype/Web.Middleware/HostingStartup.cs @@ -0,0 +1,63 @@ +// 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.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +[assembly: HostingStartup(typeof(Microsoft.AspNetCore.Watch.BrowserRefresh.HostingStartup))] + +namespace Microsoft.AspNetCore.Watch.BrowserRefresh +{ + internal sealed class HostingStartup : IHostingStartup, IStartupFilter + { + public void Configure(IWebHostBuilder builder) + { + builder.ConfigureServices(services => services.TryAddEnumerable(ServiceDescriptor.Singleton(this))); + } + + public Action Configure(Action next) + { + return app => + { + app.MapWhen( + static (context) => + { + var path = context.Request.Path; + return path.StartsWithSegments(ApplicationPaths.FrameworkRoot) && + (path.StartsWithSegments(ApplicationPaths.ClearSiteData) || + path.StartsWithSegments(ApplicationPaths.BlazorHotReloadMiddleware) || + path.StartsWithSegments(ApplicationPaths.BrowserRefreshJS) || + path.StartsWithSegments(ApplicationPaths.BlazorHotReloadJS)); + }, + static app => + { + app.Map(ApplicationPaths.ClearSiteData, static app => app.Run(context => + { + // Scoped css files can contain links to other css files. We'll try clearing out the http caches to force the browser to re-download. + // See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Clear-Site-Data#directives + context.Response.Headers["Clear-Site-Data"] = "\"cache\""; + return Task.CompletedTask; + })); + + app.Map(ApplicationPaths.BlazorHotReloadMiddleware, static app => app.UseMiddleware()); + + app.Map(ApplicationPaths.BrowserRefreshJS, + static app => app.UseMiddleware(ApplicationPaths.BrowserRefreshJS, BrowserScriptMiddleware.GetBrowserRefreshJS())); + + // backwards compat only: + app.Map(ApplicationPaths.BlazorHotReloadJS, + static app => app.UseMiddleware(ApplicationPaths.BlazorHotReloadJS, BrowserScriptMiddleware.GetBlazorHotReloadJS())); + }); + + app.UseMiddleware(); + next(app); + }; + } + } +} diff --git a/src/WatchPrototype/Web.Middleware/Microsoft.DotNet.HotReload.Web.Middleware.Package.csproj b/src/WatchPrototype/Web.Middleware/Microsoft.DotNet.HotReload.Web.Middleware.Package.csproj new file mode 100644 index 00000000000..5bdde90926b --- /dev/null +++ b/src/WatchPrototype/Web.Middleware/Microsoft.DotNet.HotReload.Web.Middleware.Package.csproj @@ -0,0 +1,56 @@ + + + + + net6.0 + true + + false + none + false + preview + + + true + true + true + Microsoft.DotNet.HotReload.Web.Middleware + false + Package containing web server middleware to support Hot Reload. + + $(NoWarn);NU5128 + + + + + + + + + + + + + + + + + + + $(TargetsForTfmSpecificContentInPackage);_AddJSFilesToSourcePackage + + + + + + + <_File Include="$(MSBuildProjectDirectory)\**\*.js" TargetDir="contentFiles/cs/$(TargetFramework)" BuildAction="EmbeddedResource" /> + + + + diff --git a/src/WatchPrototype/Web.Middleware/Microsoft.DotNet.HotReload.Web.Middleware.projitems b/src/WatchPrototype/Web.Middleware/Microsoft.DotNet.HotReload.Web.Middleware.projitems new file mode 100644 index 00000000000..acee58a7595 --- /dev/null +++ b/src/WatchPrototype/Web.Middleware/Microsoft.DotNet.HotReload.Web.Middleware.projitems @@ -0,0 +1,15 @@ + + + + $(MSBuildAllProjects);$(MSBuildThisFileFullPath) + true + 9F3A6169-66C9-4244-8FC6-222E4D6F8D29 + + + Microsoft.DotNet.HotReload + + + + + + \ No newline at end of file diff --git a/src/WatchPrototype/Web.Middleware/Microsoft.DotNet.HotReload.Web.Middleware.shproj b/src/WatchPrototype/Web.Middleware/Microsoft.DotNet.HotReload.Web.Middleware.shproj new file mode 100644 index 00000000000..6d8a9e4077c --- /dev/null +++ b/src/WatchPrototype/Web.Middleware/Microsoft.DotNet.HotReload.Web.Middleware.shproj @@ -0,0 +1,13 @@ + + + + 9F3A6169-66C9-4244-8FC6-222E4D6F8D29 + 14.0 + + + + + + + + \ No newline at end of file diff --git a/src/WatchPrototype/Web.Middleware/ResponseStreamWrapper.cs b/src/WatchPrototype/Web.Middleware/ResponseStreamWrapper.cs new file mode 100644 index 00000000000..9d2d7f93ad3 --- /dev/null +++ b/src/WatchPrototype/Web.Middleware/ResponseStreamWrapper.cs @@ -0,0 +1,199 @@ +// 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.Diagnostics; +using System.IO; +using System.IO.Compression; +using System.IO.Pipelines; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Net.Http.Headers; + +namespace Microsoft.AspNetCore.Watch.BrowserRefresh +{ + /// + /// Wraps the Response Stream to inject the WebSocket HTML into + /// an HTML Page. + /// + public class ResponseStreamWrapper : Stream + { + private static readonly MediaTypeHeaderValue s_textHtmlMediaType = new("text/html"); + + private readonly HttpContext _context; + private readonly ILogger _logger; + private bool? _isHtmlResponse; + + private Stream _baseStream; + private ScriptInjectingStream? _scriptInjectingStream; + private Pipe? _pipe; + private Task? _gzipCopyTask; + private bool _disposed; + + public ResponseStreamWrapper(HttpContext context, ILogger logger) + { + _context = context; + _baseStream = context.Response.Body; + _logger = logger; + } + + public override bool CanRead => false; + public override bool CanSeek => false; + public override bool CanWrite => true; + public override long Length { get; } + public override long Position { get; set; } + public bool ScriptInjectionPerformed => _scriptInjectingStream?.ScriptInjectionPerformed == true; + public bool IsHtmlResponse => _isHtmlResponse == true; + + public override void Flush() + { + OnWrite(); + _baseStream.Flush(); + } + + public override async Task FlushAsync(CancellationToken cancellationToken) + { + OnWrite(); + await _baseStream.FlushAsync(cancellationToken); + } + + public override void Write(ReadOnlySpan buffer) + { + OnWrite(); + _baseStream.Write(buffer); + } + + public override void WriteByte(byte value) + { + OnWrite(); + _baseStream.WriteByte(value); + } + + public override void Write(byte[] buffer, int offset, int count) + { + OnWrite(); + _baseStream.Write(buffer.AsSpan(offset, count)); + } + + public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + OnWrite(); + await _baseStream.WriteAsync(buffer.AsMemory(offset, count), cancellationToken); + } + + public override async ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + OnWrite(); + await _baseStream.WriteAsync(buffer, cancellationToken); + } + + private void OnWrite() + { + if (_isHtmlResponse.HasValue) + { + return; + } + + var response = _context.Response; + + _isHtmlResponse = + (response.StatusCode == StatusCodes.Status200OK || + response.StatusCode == StatusCodes.Status404NotFound || + response.StatusCode == StatusCodes.Status500InternalServerError) && + MediaTypeHeaderValue.TryParse(response.ContentType, out var mediaType) && + mediaType.IsSubsetOf(s_textHtmlMediaType) && + (!mediaType.Charset.HasValue || mediaType.Charset.Equals("utf-8", StringComparison.OrdinalIgnoreCase)); + + if (!_isHtmlResponse.Value) + { + BrowserRefreshMiddleware.Log.ScriptInjectionSkipped(_logger, response.StatusCode, response.ContentType); + return; + } + + BrowserRefreshMiddleware.Log.SetupResponseForBrowserRefresh(_logger); + // Since we're changing the markup content, reset the content-length + response.Headers.ContentLength = null; + + _scriptInjectingStream = new ScriptInjectingStream(_baseStream); + + // By default, write directly to the script injection stream. + // We may change the base stream below if we detect that the response + // is compressed. + _baseStream = _scriptInjectingStream; + + // Check if the response has gzip Content-Encoding + if (response.Headers.TryGetValue(HeaderNames.ContentEncoding, out var contentEncodingValues)) + { + var contentEncoding = contentEncodingValues.FirstOrDefault(); + if (string.Equals(contentEncoding, "gzip", StringComparison.OrdinalIgnoreCase)) + { + // Remove the Content-Encoding header since we'll be serving uncompressed content + response.Headers.Remove(HeaderNames.ContentEncoding); + + _pipe = new Pipe(); + var gzipStream = new GZipStream(_pipe.Reader.AsStream(leaveOpen: true), CompressionMode.Decompress, leaveOpen: true); + + _gzipCopyTask = gzipStream.CopyToAsync(_scriptInjectingStream); + _baseStream = _pipe.Writer.AsStream(leaveOpen: true); + } + } + } + + public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public override void SetLength(long value) => throw new NotSupportedException(); + + protected override void Dispose(bool disposing) + { + if (disposing) + { + DisposeAsync().AsTask().GetAwaiter().GetResult(); + } + } + + public ValueTask CompleteAsync() => DisposeAsync(); + + public override async ValueTask DisposeAsync() + { + if (_disposed) + { + return; + } + + _disposed = true; + + if (_pipe is not null) + { + await _pipe.Writer.CompleteAsync(); + } + + if (_gzipCopyTask is not null) + { + await _gzipCopyTask; + } + + if (_scriptInjectingStream is not null) + { + await _scriptInjectingStream.CompleteAsync(); + } + else + { + Debug.Assert(_isHtmlResponse != true); + await _baseStream.FlushAsync(); + } + } + } +} diff --git a/src/WatchPrototype/Web.Middleware/ScriptInjectingStream.cs b/src/WatchPrototype/Web.Middleware/ScriptInjectingStream.cs new file mode 100644 index 00000000000..477979e40de --- /dev/null +++ b/src/WatchPrototype/Web.Middleware/ScriptInjectingStream.cs @@ -0,0 +1,403 @@ +// 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.Diagnostics; +using System.IO; +using System.Runtime.CompilerServices; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.Watch.BrowserRefresh; + +internal sealed class ScriptInjectingStream : Stream +{ + internal static string InjectedScript { get; } = $""; + + private static readonly ReadOnlyMemory s_bodyTagBytes = ""u8.ToArray(); + private static readonly ReadOnlyMemory s_injectedScriptBytes = Encoding.UTF8.GetBytes(InjectedScript); + + private readonly Stream _baseStream; + + private int _partialBodyTagLength; + private bool _isDisposed; + + public ScriptInjectingStream(Stream baseStream) + { + _baseStream = baseStream; + } + + public override bool CanRead => false; + public override bool CanSeek => false; + public override bool CanWrite => true; + public override long Length { get; } + public override long Position { get; set; } + public bool ScriptInjectionPerformed { get; private set; } + + public override void Flush() + => _baseStream.Flush(); + + public override Task FlushAsync(CancellationToken cancellationToken) + => _baseStream.FlushAsync(cancellationToken); + + public override void Write(ReadOnlySpan buffer) + { + if (!ScriptInjectionPerformed) + { + ScriptInjectionPerformed = TryInjectScript(buffer); + } + else + { + _baseStream.Write(buffer); + } + } + + public override void WriteByte(byte value) + { + _baseStream.WriteByte(value); + } + + public override void Write(byte[] buffer, int offset, int count) + { + if (!ScriptInjectionPerformed) + { + ScriptInjectionPerformed = TryInjectScript(buffer.AsSpan(offset, count)); + } + else + { + _baseStream.Write(buffer, offset, count); + } + } + + public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + if (!ScriptInjectionPerformed) + { + ScriptInjectionPerformed = await TryInjectScriptAsync(buffer.AsMemory(offset, count), cancellationToken); + } + else + { + await _baseStream.WriteAsync(buffer.AsMemory(offset, count), cancellationToken); + } + } + + public override async ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + if (!ScriptInjectionPerformed) + { + ScriptInjectionPerformed = await TryInjectScriptAsync(buffer, cancellationToken); + } + else + { + await _baseStream.WriteAsync(buffer, cancellationToken); + } + } + + private bool TryInjectScript(ReadOnlySpan buffer) + { + var sourceBuffer = new SourceBuffer(buffer); + var writer = new SyncBaseStreamWriter(_baseStream); + return TryInjectScriptCore(ref writer, sourceBuffer); + } + + private async ValueTask TryInjectScriptAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken) + { + var sourceBuffer = new SourceBuffer(buffer.Span); + var writer = new AsyncBaseStreamWriter(_baseStream, buffer); + var result = TryInjectScriptCore(ref writer, sourceBuffer); + + await writer.WriteToBaseStreamAsync(cancellationToken); + + return result; + } + + // Implements the core script injection logic in a manner agnostic to whether writes + // are synchronous or asynchronous. + private bool TryInjectScriptCore(ref TWriter writer, SourceBuffer buffer) + where TWriter : struct, IBaseStreamWriter + { + if (_partialBodyTagLength != 0) + { + // We're in the middle of parsing a potential body tag, + // which means that the rest of the body tag must be + // at the start of the buffer. + + var restPartialTagLength = FindPartialTagLengthFromStart(currentBodyTagLength: _partialBodyTagLength, buffer.Span); + if (restPartialTagLength == -1) + { + // This wasn't a closing body tag. Flush what we've buffered so far and reset. + // We don't return here because we want to continue to process the buffer as if + // we weren't reading a partial body tag. + writer.Write(s_bodyTagBytes[.._partialBodyTagLength]); + _partialBodyTagLength = 0; + } + else + { + // This may still be a closing body tag. + _partialBodyTagLength += restPartialTagLength; + + Debug.Assert(_partialBodyTagLength <= s_bodyTagBytes.Length); + + if (_partialBodyTagLength == s_bodyTagBytes.Length) + { + // We've just read a full closing body tag, so we flush it to the stream. + // Then just write the rest of the stream normally as we've now finished searching + // for the script. + writer.Write(s_injectedScriptBytes); + writer.Write(s_bodyTagBytes); + writer.Write(buffer[restPartialTagLength..]); + _partialBodyTagLength = 0; + return true; + } + else + { + // We're still in the middle of reading the body tag, + // so there's nothing else to flush to the stream. + return false; + } + } + } + + // We now know we're not in the middle of processing a body tag. + Debug.Assert(_partialBodyTagLength == 0); + + var index = buffer.Span.LastIndexOf(s_bodyTagBytes.Span); + if (index == -1) + { + // We didn't find the full closing body tag in the buffer, but the end of the buffer + // might contain the start of a closing body tag. + + var partialBodyTagLength = FindPartialTagLengthFromEnd(buffer.Span); + if (partialBodyTagLength == -1) + { + // We know that the end of the buffer definitely does not + // represent a closing body tag. We'll just flush the buffer + // to the base stream. + writer.Write(buffer); + return false; + } + else + { + // We might have found a body tag at the end of the buffer. + // We'll write the buffer leading up to the start of the body + // tag candidate. + + writer.Write(buffer[..^partialBodyTagLength]); + _partialBodyTagLength = partialBodyTagLength; + return false; + } + } + + if (index > 0) + { + writer.Write(buffer[..index]); + buffer = buffer[index..]; + } + + // Write the injected script + writer.Write(s_injectedScriptBytes); + + // Write the rest of the buffer/HTML doc + writer.Write(buffer); + return true; + } + + private static int FindPartialTagLengthFromStart(int currentBodyTagLength, ReadOnlySpan buffer) + { + var remainingBodyTagBytes = s_bodyTagBytes.Span[currentBodyTagLength..]; + var minLength = Math.Min(buffer.Length, remainingBodyTagBytes.Length); + + return buffer[..minLength].SequenceEqual(remainingBodyTagBytes[..minLength]) + ? minLength + : -1; + } + + private static int FindPartialTagLengthFromEnd(ReadOnlySpan buffer) + { + var bufferLength = buffer.Length; + if (bufferLength == 0) + { + return -1; + } + + // Since each character within "" is unique, we can use the last byte + // in the buffer to determine the length of the partial body tag. + var lastByte = buffer[^1]; + var bodyMarkerIndexOfLastByte = BodyTagIndexOf(lastByte); + if (bodyMarkerIndexOfLastByte == -1) + { + // The last character does not appear "", so we know + // there's not a partial body tag. + return -1; + } + + var partialTagLength = bodyMarkerIndexOfLastByte + 1; + if (buffer.Length < partialTagLength) + { + // The buffer is shorter than the expected length of the partial + // body tag, so we know the buffer can't possibly contain it. + return -1; + } + + // Finally, we need to check that the content at the end of the buffer + // matches the expected partial body tag. + return buffer[^partialTagLength..].SequenceEqual(s_bodyTagBytes.Span[..partialTagLength]) + ? partialTagLength + : -1; + + // We can utilize the fact that each character is unique in "" + // to perform an efficient index lookup. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + static int BodyTagIndexOf(byte c) + => c switch + { + (byte)'<' => 0, + (byte)'/' => 1, + (byte)'b' => 2, + (byte)'o' => 3, + (byte)'d' => 4, + (byte)'y' => 5, + (byte)'>' => 6, + _ => -1, + }; + } + + public ValueTask CompleteAsync() => DisposeAsync(); + + protected override void Dispose(bool disposing) + { + if (disposing) + { + DisposeAsync().AsTask().GetAwaiter().GetResult(); + } + } + + public override async ValueTask DisposeAsync() + { + if (_isDisposed) + { + return; + } + + _isDisposed = true; + + if (_partialBodyTagLength > 0) + { + // We might have buffered some data thinking that it could represent + // a body tag. We know at this point that there's no more data + // on its way, so we'll write the remaining data to the buffer. + await _baseStream.WriteAsync(s_bodyTagBytes[.._partialBodyTagLength]); + _partialBodyTagLength = 0; + } + + await FlushAsync(); + } + + public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public override void SetLength(long value) => throw new NotSupportedException(); + + // A thin wrapper over ReadOnlySpan that keeps track of the current range relative + // to the originally-provided buffer. + // This enables the sharing of logic between scenarios where only a ReadOnlySpan + // is available (some synchronous writes) and scenarios where ReadOnlyMemory + // is required (all asynchronous writes). + private readonly ref struct SourceBuffer + { + private readonly int _offsetFromOriginal; + + public readonly ReadOnlySpan Span; + + public int Length => Span.Length; + + public Range RangeInOriginal => new(_offsetFromOriginal, _offsetFromOriginal + Span.Length); + + public SourceBuffer(ReadOnlySpan span) + : this(span, offsetFromOriginal: 0) + { + } + + private SourceBuffer(ReadOnlySpan span, int offsetFromOriginal) + { + Span = span; + _offsetFromOriginal = offsetFromOriginal; + } + + public SourceBuffer Slice(int start, int length) + => new(Span.Slice(start, length), offsetFromOriginal: _offsetFromOriginal + start); + } + + // Represents a writer to the base stream. + // Accepts arbitrary heap-allocated buffers and + // ranges of the source buffer. + private interface IBaseStreamWriter + { + void Write(in SourceBuffer buffer); + void Write(ReadOnlyMemory data); + } + + // A base stream writer that performs synchronous writes. + private struct SyncBaseStreamWriter(Stream baseStream) : IBaseStreamWriter + { + public readonly void Write(ReadOnlyMemory data) => baseStream.Write(data.Span); + public readonly void Write(in SourceBuffer buffer) => baseStream.Write(buffer.Span); + } + + // A base stream writer that enables buffering writes synchronously and applying + // them to the base stream when an async context is available. + private struct AsyncBaseStreamWriter(Stream baseStream, ReadOnlyMemory bufferMemory) : IBaseStreamWriter + { + private WriteBuffer _writes; + private int _writeCount; + + public void Write(ReadOnlyMemory data) => _writes[_writeCount++] = data; + public void Write(in SourceBuffer buffer) => _writes[_writeCount++] = bufferMemory[buffer.RangeInOriginal]; + + public readonly async ValueTask WriteToBaseStreamAsync(CancellationToken cancellationToken) + { + for (var i = 0; i < _writeCount; i++) + { + await baseStream.WriteAsync(_writes[i], cancellationToken); + } + } + + // We don't currently need than 4 writes, but we can bump this in the future if needed. + // If we ever target .NET 8+, we can use the [InlineArray] feature instead. + private struct WriteBuffer + { + ReadOnlyMemory _write0; + ReadOnlyMemory _write1; + ReadOnlyMemory _write2; + ReadOnlyMemory _write3; + + private static ref ReadOnlyMemory GetAt(ref WriteBuffer buffer, int index) + { + switch (index) + { + case 0: return ref buffer._write0; + case 1: return ref buffer._write1; + case 2: return ref buffer._write2; + case 3: return ref buffer._write3; + default: throw new IndexOutOfRangeException(nameof(index)); + } + } + + public ReadOnlyMemory this[int index] + { + get => GetAt(ref this, index); + set => GetAt(ref this, index) = value; + } + } + } +} diff --git a/src/WatchPrototype/Web.Middleware/StartupHook.cs b/src/WatchPrototype/Web.Middleware/StartupHook.cs new file mode 100644 index 00000000000..01ae4ee90a0 --- /dev/null +++ b/src/WatchPrototype/Web.Middleware/StartupHook.cs @@ -0,0 +1,16 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable enable + +using System; + +internal class StartupHook +{ + public static void Initialize() + { + // See https://github.com/dotnet/aspnetcore/issues/37357#issuecomment-941237000 + // We'll configure an environment variable that will indicate to blazor-wasm that the middleware is available. + Environment.SetEnvironmentVariable("__ASPNETCORE_BROWSER_TOOLS", "true"); + } +} diff --git a/src/WatchPrototype/Web.Middleware/WebSocketScriptInjection.js b/src/WatchPrototype/Web.Middleware/WebSocketScriptInjection.js new file mode 100644 index 00000000000..e03aaeb5d47 --- /dev/null +++ b/src/WatchPrototype/Web.Middleware/WebSocketScriptInjection.js @@ -0,0 +1,358 @@ +setTimeout(async function () { + const hotReloadActiveKey = '_dotnet_watch_hot_reload_active'; + // Ensure we only try to connect once, even if the script is both injected and manually inserted + const scriptInjectedSentinel = '_dotnet_watch_ws_injected'; + if (window.hasOwnProperty(scriptInjectedSentinel)) { + return; + } + window[scriptInjectedSentinel] = true; + + // dotnet-watch browser reload script + const webSocketUrls = '{{hostString}}'.split(','); + const sharedSecret = await getSecret('{{ServerKey}}'); + let connection; + for (const url of webSocketUrls) { + try { + connection = await getWebSocket(url); + break; + } catch (ex) { + console.debug(ex); + } + } + if (!connection) { + console.debug('Unable to establish a connection to the browser refresh server.'); + return; + } + + let waiting = false; + + connection.onmessage = function (message) { + const payload = JSON.parse(message.data); + const action = { + 'Reload': () => reload(), + 'Wait': () => wait(), + 'UpdateStaticFile': () => updateStaticFile(payload.path), + 'ApplyManagedCodeUpdates': () => applyManagedCodeUpdates(payload.sharedSecret, payload.updateId, payload.deltas, payload.responseLoggingLevel), + 'ReportDiagnostics': () => reportDiagnostics(payload.diagnostics), + 'GetApplyUpdateCapabilities': () => getApplyUpdateCapabilities(), + 'RefreshBrowser': () => refreshBrowser() + }; + + if (payload.type && action.hasOwnProperty(payload.type)) { + action[payload.type](); + } else { + console.error('Unknown payload:', message.data); + } + } + + connection.onerror = function (event) { console.debug('dotnet-watch reload socket error.', event) } + connection.onclose = function () { console.debug('dotnet-watch reload socket closed.') } + connection.onopen = function () { console.debug('dotnet-watch reload socket connected.') } + + function updateStaticFile(path) { + if (path && path.endsWith('.css')) { + updateCssByPath(path); + } else { + console.debug(`File change detected to file ${path}. Reloading page...`); + location.reload(); + return; + } + } + + async function updateCssByPath(path) { + const styleElement = document.querySelector(`link[href^="${path}"]`) || + document.querySelector(`link[href^="${document.baseURI}${path}"]`); + + // Receive a Clear-site-data header. + await fetch('/_framework/clear-browser-cache'); + + if (!styleElement || !styleElement.parentNode) { + console.debug('Unable to find a stylesheet to update. Updating all local css files.'); + updateAllLocalCss(); + } + + updateCssElement(styleElement); + } + + function updateAllLocalCss() { + [...document.querySelectorAll('link')] + .filter(l => l.baseURI === document.baseURI) + .forEach(e => updateCssElement(e)); + } + + function getMessageAndStack(error) { + const message = error.message || '' + let messageAndStack = error.stack || message + if (!messageAndStack.includes(message)) { + messageAndStack = message + "\n" + messageAndStack; + } + + return messageAndStack + } + + function getApplyUpdateCapabilities() { + let applyUpdateCapabilities; + try { + applyUpdateCapabilities = window.Blazor._internal.getApplyUpdateCapabilities(); + } catch (error) { + applyUpdateCapabilities = "!" + getMessageAndStack(error); + } + connection.send(applyUpdateCapabilities); + } + + function updateCssElement(styleElement) { + if (!styleElement || styleElement.loading) { + // A file change notification may be triggered for the same file before the browser + // finishes processing a previous update. In this case, it's easiest to ignore later updates + return; + } + + const newElement = styleElement.cloneNode(); + const href = styleElement.href; + newElement.href = href.split('?', 1)[0] + `?nonce=${Date.now()}`; + + styleElement.loading = true; + newElement.loading = true; + newElement.addEventListener('load', function () { + newElement.loading = false; + styleElement.remove(); + }); + + styleElement.parentNode.insertBefore(newElement, styleElement.nextSibling); + } + + function applyDeltas_legacy(deltas) { + let apply = window.Blazor?._internal?.applyHotReload + + // Only apply hot reload deltas if Blazor has been initialized. + // It's possible for Blazor to start after the initial page load, so we don't consider skipping this step + // to be a failure. These deltas will get applied later, when Blazor completes initialization. + if (apply) { + deltas.forEach(d => { + if (apply.length == 5) { + // WASM 8.0 + apply(d.moduleId, d.metadataDelta, d.ilDelta, d.pdbDelta, d.updatedTypes) + } else { + // WASM 9.0 + apply(d.moduleId, d.metadataDelta, d.ilDelta, d.pdbDelta) + } + }); + } + } + + async function applyManagedCodeUpdates(serverSecret, updateId, deltas, responseLoggingLevel) { + if (sharedSecret && (serverSecret != sharedSecret.encodedSharedSecret)) { + // Validate the shared secret if it was specified. It might be unspecified in older versions of VS + // that do not support this feature as yet. + throw 'Unable to validate the server. Rejecting apply-update payload.'; + } + + console.debug('Applying managed code updates.'); + + const AgentMessageSeverity_Error = 2 + + let applyError = undefined; + let log = []; + try { + let applyDeltas = window.Blazor?._internal?.applyHotReloadDeltas + if (applyDeltas) { + // Only apply hot reload deltas if Blazor has been initialized. + // It's possible for Blazor to start after the initial page load, so we don't consider skipping this step + // to be a failure. These deltas will get applied later, when Blazor completes initialization. + + let wasmDeltas = deltas.map(delta => { + return { + "moduleId": delta.moduleId, + "metadataDelta": delta.metadataDelta, + "ilDelta": delta.ilDelta, + "pdbDelta": delta.pdbDelta, + "updatedTypes": delta.updatedTypes, + }; + }); + + log = applyDeltas(wasmDeltas, responseLoggingLevel); + } else { + // Try invoke older WASM API: + applyDeltas_legacy(deltas) + } + } catch (error) { + console.warn(error); + applyError = error; + log.push({ "message": getMessageAndStack(error), "severity": AgentMessageSeverity_Error }); + } + + try { + let body = JSON.stringify({ + "id": updateId, + "deltas": deltas + }); + + await fetch('/_framework/blazor-hotreload', { method: 'post', headers: { 'content-type': 'application/json' }, body: body }); + } catch (error) { + console.warn(error); + applyError = error; + log.push({ "message": getMessageAndStack(error), "severity": AgentMessageSeverity_Error }); + } + + connection.send(JSON.stringify({ + "success": !applyError, + "log": log + })); + + if (!applyError) { + displayChangesAppliedToast(); + } + } + + function reportDiagnostics(diagnostics) { + console.debug('Reporting Hot Reload diagnostics.'); + + document.querySelectorAll('#dotnet-compile-error').forEach(el => el.remove()); + + if (diagnostics.length == 0) { + return; + } + + const el = document.body.appendChild(document.createElement('div')); + el.id = 'dotnet-compile-error'; + el.setAttribute('style', 'z-index:1000000; position:fixed; top: 0; left: 0; right: 0; bottom: 0; background-color: rgba(0,0,0,0.5); color:black; overflow: scroll;'); + diagnostics.forEach(error => { + const item = el.appendChild(document.createElement('div')); + item.setAttribute('style', 'border: 2px solid red; padding: 8px; background-color: #faa;') + const message = item.appendChild(document.createElement('div')); + message.setAttribute('style', 'font-weight: bold'); + message.textContent = error.Message; + item.appendChild(document.createElement('div')).textContent = error; + }); + } + + function displayChangesAppliedToast() { + document.querySelectorAll('#dotnet-compile-error').forEach(el => el.remove()); + if (document.querySelector('#dotnet-hotreload-toast')) { + return; + } + if (!window[hotReloadActiveKey]) + { + return; + } + const el = document.createElement('div'); + el.id = 'dotnet-hotreload-toast'; + el.innerHTML = ""; + el.setAttribute('style', 'z-index: 1000000; width: 48px; height: 48px; position:fixed; top:5px; left: 5px'); + document.body.appendChild(el); + window[hotReloadActiveKey] = false; + setTimeout(() => el.remove(), 2000); + } + + function refreshBrowser() { + if (window.Blazor) { + window[hotReloadActiveKey] = true; + // hotReloadApplied triggers an enhanced navigation to + // refresh pages that have been statically rendered with + // Blazor SSR. + if (window.Blazor?._internal?.hotReloadApplied) + { + console.debug('Refreshing browser: WASM.'); + Blazor._internal.hotReloadApplied(); + } + else + { + console.debug('Refreshing browser.'); + displayChangesAppliedToast(); + } + } else { + console.debug('Refreshing browser: Reloading.'); + location.reload(); + } + } + + function reload() { + console.debug('Reloading.'); + location.reload(); + } + + function wait() { + console.debug('Waiting for application to rebuild.'); + + if (waiting) { + return; + } + + waiting = true; + const glyphs = ['☱', '☲', '☴']; + const title = document.title; + let i = 0; + setInterval(function () { document.title = glyphs[i++ % glyphs.length] + ' ' + title; }, 240); + } + + async function getSecret(serverKeyString) { + if (!serverKeyString || !window.crypto || !window.crypto.subtle) { + return null; + } + + const secretBytes = window.crypto.getRandomValues(new Uint8Array(32)); // 32-bytes of entropy + + // Based on https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/importKey#subjectpublickeyinfo_import + const binaryServerKey = str2ab(atob(serverKeyString)); + const serverKey = await window.crypto.subtle.importKey('spki', binaryServerKey, { name: "RSA-OAEP", hash: "SHA-256" }, false, ['encrypt']); + const encrypted = await window.crypto.subtle.encrypt({ name: 'RSA-OAEP' }, serverKey, secretBytes); + return { + encryptedSharedSecret: btoa(String.fromCharCode(...new Uint8Array(encrypted))), + encodedSharedSecret: btoa(String.fromCharCode(...secretBytes)), + }; + + function str2ab(str) { + const buf = new ArrayBuffer(str.length); + const bufView = new Uint8Array(buf); + for (let i = 0, strLen = str.length; i < strLen; i++) { + bufView[i] = str.charCodeAt(i); + } + return buf; + } + } + + function getWebSocket(url) { + return new Promise((resolve, reject) => { + const encryptedSecret = sharedSecret && sharedSecret.encryptedSharedSecret; + const protocol = encryptedSecret ? encodeURIComponent(encryptedSecret) : []; + const webSocket = new WebSocket(url, protocol); + let opened = false; + + function onOpen() { + opened = true; + clearEventListeners(); + resolve(webSocket); + } + + function onClose(event) { + if (opened) { + // Open completed successfully. Nothing to do here. + return; + } + + let error = 'WebSocket failed to connect.'; + if (event instanceof ErrorEvent) { + error = event.error; + } + + clearEventListeners(); + reject(error); + } + + function clearEventListeners() { + webSocket.removeEventListener('open', onOpen); + // The error event isn't as reliable, but close is always called even during failures. + // If close is called without a corresponding open, we can reject the promise. + webSocket.removeEventListener('close', onClose); + } + + webSocket.addEventListener('open', onOpen); + webSocket.addEventListener('close', onClose); + if (window.Blazor?.removeEventListener && window.Blazor?.addEventListener) + { + webSocket.addEventListener('close', () => window.Blazor?.removeEventListener('enhancedload', displayChangesAppliedToast)); + window.Blazor?.addEventListener('enhancedload', displayChangesAppliedToast); + } + }); + } +}, 500);