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 @@
+
+
+
+
+