[dotnet-watch] http transport for mobile#52581
[dotnet-watch] http transport for mobile#52581jonathanpeppers wants to merge 10 commits intodotnet:release/10.0.3xxfrom
Conversation
Context: dotnet/sdk#52492 Context: dotnet/sdk#52581 `dotnet-watch` now runs Android applications via: dotnet watch 🚀 [helloandroid (net10.0-android)] Launched 'D:\src\xamarin-android\bin\Debug\dotnet\dotnet.exe' with arguments 'run --no-build -e DOTNET_WATCH=1 -e DOTNET_WATCH_ITERATION=1 -e DOTNET_MODIFIABLE_ASSEMBLIES=debug -e DOTNET_WATCH_HOTRELOAD_WEBSOCKET_ENDPOINT=ws://localhost:9000 -e DOTNET_STARTUP_HOOKS=D:\src\xamarin-android\bin\Debug\dotnet\sdk\10.0.300-dev\DotnetTools\dotnet-watch\10.0.300-dev\tools\net10.0\any\hotreload\net10.0\Microsoft.Extensions.DotNetDeltaApplier.dll -bl': process id 3356 And so the pieces on Android for this to work are: ~~ Startup Hook Assembly ~~ Parse out the value: <_AndroidHotReloadAgentAssemblyPath>@(RuntimeEnvironmentVariable->WithMetadataValue('Identity', 'DOTNET_STARTUP_HOOKS')->'%(Value)'->Exists())</_AndroidHotReloadAgentAssemblyPath> And verify this assembly is included in the app: <ResolvedFileToPublish Include="$(_AndroidHotReloadAgentAssemblyPath)" /> Then, for Android, we need to patch up `$DOTNET_STARTUP_HOOKS` to be just the assembly name, not the full path: <_AndroidHotReloadAgentAssemblyName>$([System.IO.Path]::GetFileNameWithoutExtension('$(_AndroidHotReloadAgentAssemblyPath)'))</_AndroidHotReloadAgentAssemblyName> ... <RuntimeEnvironmentVariable Include="DOTNET_STARTUP_HOOKS" Value="$(_AndroidHotReloadAgentAssemblyName)" /> ~~ Port Forwarding ~~ A new `_AndroidConfigureAdbReverse` target runs after deploying apps, that does: adb reverse tcp:9000 tcp:9000 I parsed the value out of: <_AndroidWebSocketEndpoint>@(RuntimeEnvironmentVariable->WithMetadataValue('Identity', 'DOTNET_WATCH_HOTRELOAD_WEBSOCKET_ENDPOINT')->'%(Value)')</_AndroidWebSocketEndpoint> <_AndroidWebSocketPort>$([System.Text.RegularExpressions.Regex]::Match('$(_AndroidWebSocketEndpoint)', ':(\d+)').Groups[1].Value)</_AndroidWebSocketPort> ~~ Prevent Startup Hooks in Microsoft.Android.Run ~~ When I was implementing this, I keep seeing *two* clients connect to `dotnet-watch` and I was pulling my hair to figure out why! Then I realized that `Microsoft.Android.Run` was also getting `$DOTNET_STARTUP_HOOKS`, and so we had a desktop process + mobile process both trying to connect! Easiest fix, is to disable startup hook support in `Microsoft.Android.Run`. I reviewed the code in `dotnet run`, and it doesn't seem correct to try to clear the env vars. ~~ Conclusion ~~ With these changes, everything is working! dotnet watch 🔥 C# and Razor changes applied in 23ms. This will depend on getting changes in dotnet/sdk before we merge.
Context: dotnet/sdk#52492 Context: dotnet/sdk#52581 `dotnet-watch` now runs Android applications via: dotnet watch 🚀 [helloandroid (net10.0-android)] Launched 'D:\src\xamarin-android\bin\Debug\dotnet\dotnet.exe' with arguments 'run --no-build -e DOTNET_WATCH=1 -e DOTNET_WATCH_ITERATION=1 -e DOTNET_MODIFIABLE_ASSEMBLIES=debug -e DOTNET_WATCH_HOTRELOAD_WEBSOCKET_ENDPOINT=ws://localhost:9000 -e DOTNET_STARTUP_HOOKS=D:\src\xamarin-android\bin\Debug\dotnet\sdk\10.0.300-dev\DotnetTools\dotnet-watch\10.0.300-dev\tools\net10.0\any\hotreload\net10.0\Microsoft.Extensions.DotNetDeltaApplier.dll -bl': process id 3356 And so the pieces on Android for this to work are: ~~ Startup Hook Assembly ~~ Parse out the value: <_AndroidHotReloadAgentAssemblyPath>@(RuntimeEnvironmentVariable->WithMetadataValue('Identity', 'DOTNET_STARTUP_HOOKS')->'%(Value)'->Exists())</_AndroidHotReloadAgentAssemblyPath> And verify this assembly is included in the app: <ResolvedFileToPublish Include="$(_AndroidHotReloadAgentAssemblyPath)" /> Then, for Android, we need to patch up `$DOTNET_STARTUP_HOOKS` to be just the assembly name, not the full path: <_AndroidHotReloadAgentAssemblyName>$([System.IO.Path]::GetFileNameWithoutExtension('$(_AndroidHotReloadAgentAssemblyPath)'))</_AndroidHotReloadAgentAssemblyName> ... <RuntimeEnvironmentVariable Include="DOTNET_STARTUP_HOOKS" Value="$(_AndroidHotReloadAgentAssemblyName)" /> ~~ Port Forwarding ~~ A new `_AndroidConfigureAdbReverse` target runs after deploying apps, that does: adb reverse tcp:9000 tcp:9000 I parsed the value out of: <_AndroidWebSocketEndpoint>@(RuntimeEnvironmentVariable->WithMetadataValue('Identity', 'DOTNET_WATCH_HOTRELOAD_WEBSOCKET_ENDPOINT')->'%(Value)')</_AndroidWebSocketEndpoint> <_AndroidWebSocketPort>$([System.Text.RegularExpressions.Regex]::Match('$(_AndroidWebSocketEndpoint)', ':(\d+)').Groups[1].Value)</_AndroidWebSocketPort> ~~ Prevent Startup Hooks in Microsoft.Android.Run ~~ When I was implementing this, I keep seeing *two* clients connect to `dotnet-watch` and I was pulling my hair to figure out why! Then I realized that `Microsoft.Android.Run` was also getting `$DOTNET_STARTUP_HOOKS`, and so we had a desktop process + mobile process both trying to connect! Easiest fix, is to disable startup hook support in `Microsoft.Android.Run`. I reviewed the code in `dotnet run`, and it doesn't seem correct to try to clear the env vars. ~~ Conclusion ~~ With these changes, everything is working! dotnet watch 🔥 C# and Razor changes applied in 23ms. This will depend on getting changes in dotnet/sdk before we merge.
There was a problem hiding this comment.
Pull request overview
This PR adds a WebSocket-based hot reload transport to dotnet watch to support mobile scenarios (Android/iOS) where named pipes aren’t viable, selecting the transport via a new HotReloadWebSockets project capability.
Changes:
- Add WebSocket transport selection for hot reload (capability detection + mobile app model) and plumb a configurable port via environment options.
- Implement WebSocket server/client plumbing for hot reload (watch side Kestrel WebSocket server + agent-side WebSocket transport) while reusing the existing binary delta protocol.
- Add a new test project + test coverage validating WebSocket transport selection and hot reload behavior.
Reviewed changes
Copilot reviewed 20 out of 20 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| test/dotnet-watch.Tests/HotReload/MobileHotReloadTests.cs | Adds end-to-end test verifying WebSocket hot reload path is selected and works. |
| test/TestAssets/TestProjects/WatchMobileApp/WatchMobileApp.csproj | New test asset project that opts into WebSocket transport via HotReloadWebSockets capability. |
| test/TestAssets/TestProjects/WatchMobileApp/Program.cs | New test app used by dotnet watch tests to validate hot reload output changes. |
| test/Microsoft.Extensions.DotNetDeltaApplier.Tests/HotReloadClientTests.cs | Updates agent/client tests to use the new transport abstraction and listener. |
| src/BuiltInTools/Watch/UI/IReporter.cs | Adds a new debug descriptor to log “Application kind: WebSockets.” |
| src/BuiltInTools/Watch/Context/EnvironmentVariables.cs | Adds env var names + parsing for the hot reload HTTP port override and WebSocket endpoint name. |
| src/BuiltInTools/Watch/Context/EnvironmentOptions.cs | Adds HotReloadHttpPort option and wires it to environment variable reading. |
| src/BuiltInTools/Watch/Build/ProjectGraphUtilities.cs | Adds capability-based detection for WebSocket hot reload projects. |
| src/BuiltInTools/Watch/Build/BuildNames.cs | Introduces ProjectCapability.HotReloadWebSockets constant. |
| src/BuiltInTools/Watch/AppModels/MobileAppModel.cs | New app model that creates a MobileHotReloadClient (WebSocket-based). |
| src/BuiltInTools/Watch/AppModels/HotReloadAppModel.cs | Selects the mobile/WebSocket app model when capability is present. |
| src/BuiltInTools/HotReloadClient/Web/KestrelWebSocketServer.cs | New Kestrel-hosted WebSocket server base used by mobile hot reload. |
| src/BuiltInTools/HotReloadClient/MobileHotReloadClient.cs | Implements watch-side WebSocket hot reload client + server coordination. |
| src/BuiltInTools/HotReloadAgent.Host/Transport.cs | Introduces a transport abstraction for agent communications. |
| src/BuiltInTools/HotReloadAgent.Host/NamedPipeTransport.cs | Moves named-pipe specifics behind the new Transport abstraction. |
| src/BuiltInTools/HotReloadAgent.Host/WebSocketTransport.cs | Adds WebSocket transport implementation for the agent startup hook. |
| src/BuiltInTools/HotReloadAgent.Host/Listener.cs | Refactors the listener to work over the new transport abstraction (pipe or WebSocket). |
| src/BuiltInTools/HotReloadAgent.Host/StartupHook.cs | Switches startup hook connection logic to choose transport from env vars. |
| src/BuiltInTools/HotReloadAgent.Data/AgentEnvironmentVariables.cs | Adds DOTNET_WATCH_HOTRELOAD_WEBSOCKET_ENDPOINT constant. |
| documentation/specs/dotnet-watch-for-maui.md | Adds a spec describing capability detection, env vars, and workload integration points. |
test/Microsoft.Extensions.DotNetDeltaApplier.Tests/HotReloadClientTests.cs
Show resolved
Hide resolved
This was using a HTTP listener at first, later switched to WebSockets.
9001e8b to
41e4074
Compare
* Now uses `dotnet run -e` environment variables for everything * Now uses web sockets * Adds tests for mobile hot reload
Introduce RSA-based authentication for mobile hot reload WebSocket connections. Adds DOTNET_WATCH_HOTRELOAD_WEBSOCKET_KEY env var and constant, and propagates the server public key to the client. WebSocketTransport now accepts a server public key, encrypts a random 32-byte secret with RSA-OAEP-SHA256, and sends it as a URL-safe Base64 subprotocol token. HotReloadWebSocketServer uses SharedSecretProvider to expose the public key and to decrypt/validate the incoming secret, rejecting connections with missing/invalid secrets. Also adds a Base64Url helper for URL-safe Base64 encoding/decoding and updates documentation and cleanup to dispose the shared secret provider.
41e4074 to
9af2d80
Compare
| var encrypted = rsa.Encrypt(secret, RSAEncryptionPadding.OaepSHA256); | ||
|
|
||
| // Convert to URL-safe Base64 for WebSocket subprotocol header | ||
| return Base64Url.EncodeToString(encrypted); |
There was a problem hiding this comment.
Can we use Convert.ToBase64String?
There was a problem hiding this comment.
It needs to use System.Buffers.Text.Base64Url.EncodeToString() because of +, /, and trailing =.
I called the BCL for .NET 8+ where they added this API, but I had to write a version for netstandard2.0.
| /// Port for HTTP hot reload communication. Used for projects with the HotReloadWebSockets capability. | ||
| /// Mobile workloads (Android, iOS) add this capability. Defaults to 0 (auto-assign) if not specified. | ||
| /// </summary> | ||
| public static int HotReloadHttpPort => ReadInt(Names.DotNetWatchHotReloadHttpPort) ?? 0; |
There was a problem hiding this comment.
We need two ports - one for HTTP and another one for HTTPs, right? Otherwise, they're gonna collide.
We have similar problem with AutoReloadWebSocketPort: #52959
There was a problem hiding this comment.
Let's call this variable AgentWebSocketPort (and AgentWebSocketSecurePort for wss).
(A better name for AutoReloadWebSocketPort would be BrowserWebSocketPort. I'll rename it in #52959.).
| /// | ||
| /// Server-side implementation using Kestrel + WebSockets, extends KestrelWebSocketServer. | ||
| /// </summary> | ||
| internal sealed class MobileHotReloadClient : HotReloadClient |
There was a problem hiding this comment.
Most of the code, other than sending/receiving messages is now the same as DefaultHotReloadClient.
I think it'd make sense to define similar Transport abstraction as we have in the agent and use DefaultHotReloadClient for both cases. It would create named pipe/web socket transport layer depending on given parameters.
There was a problem hiding this comment.
Addressed in 7798648, 🤞 the tests still pass.
It does look a lot cleaner this way.
|
|
||
| public async ValueTask StartServerAsync(string hostName, int port, CancellationToken cancellationToken) | ||
| { | ||
| await base.StartServerAsync(hostName, port, useTls: false, cancellationToken); |
There was a problem hiding this comment.
I'd move IsTlsSupportedAsync, GetServerUrls and the body of CreateAndStartHostAsync from BowserRefreshServer to KestrelWebSocketServer, create an instance of KestrelWebSocketServer in BowserRefreshServer and delegate the implementation to it.
…ientTransport` abstraction Addressing: dotnet#52581 (comment)
| { | ||
| log($"{AgentEnvironmentVariables.DotNetWatchHotReloadWebSocketEndpoint}={webSocketEndpoint}"); | ||
| if (!Uri.TryCreate(webSocketEndpoint, UriKind.Absolute, out var uri) || | ||
| (uri.Scheme != "ws" && uri.Scheme != "wss")) |
There was a problem hiding this comment.
Nit: could be uri.Scheme is not ("ws" or "wss")
|
|
||
| public override async Task<string> WaitForConnectionAsync(CancellationToken cancellationToken) | ||
| { | ||
| #if NET |
There was a problem hiding this comment.
We can move creation of the pip[e to the constructor and then remove all _pipe != null checks in the type.
| 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; |
There was a problem hiding this comment.
I'd return Stream? from this method and moved reading the initialization message to the caller, to avoid leaking the protocol into the transport layer.
There was a problem hiding this comment.
Actually, can we just create the connection here and then call ReadAsync to read the initial message in the caller instead?
|
|
||
| logger.LogDebug("Waiting for application to connect to pipe {NamedPipeName}.", _namedPipeName); | ||
|
|
||
| await _pipe.WaitForConnectionAsync(cancellationToken); |
There was a problem hiding this comment.
I think we need to keep the try-catch around this call. The process may die while we waiting for the connection and the pipe may be disposed.
catch (Exception e) when (e is not OperationCanceledException)
{
ReportPipeReadException(e, "capabilities", cancellationToken);
return null;
}
| applyOperationCancellationToken); | ||
| } | ||
|
|
||
| private async Task ListenForResponsesAsync(CancellationToken cancellationToken) |
There was a problem hiding this comment.
Nit: Would you mind moving this before GetCapabilitiesTask() method, so that it's easier to see the differences and merge any conflicts?
| /// <param name="useTls">Whether to enable HTTPS</param> | ||
| /// <param name="cancellationToken">Cancellation token</param> | ||
| protected async ValueTask StartServerAsync(string hostName = "localhost", int port = 0, bool useTls = false, CancellationToken cancellationToken = default) | ||
| public async ValueTask StartServerAsync(string hostName, int port, bool useTls, CancellationToken cancellationToken) |
There was a problem hiding this comment.
This needs two ports to avoid collision (ws and wss)
There was a problem hiding this comment.
You can replace useTls with int? securePort.
| /// <summary> | ||
| /// Converts an HTTP(S) URL to a WebSocket URL. | ||
| /// </summary> | ||
| protected virtual string ConvertToWebSocketUrl(string httpUrl) |
| /// <summary> | ||
| /// Helper to accept a WebSocket connection. | ||
| /// </summary> | ||
| protected async ValueTask<WebSocket?> AcceptWebSocketAsync(HttpContext context, string? subProtocol = null) |
There was a problem hiding this comment.
Only called from AgentWebSocketServer.HandleRequestAsync. We can inline it.
| /// Handles incoming HTTP requests. Override to implement WebSocket logic. | ||
| /// </summary> | ||
| protected abstract Task HandleRequestAsync(HttpContext context); | ||
| protected virtual Task HandleRequestAsync(HttpContext context) |
There was a problem hiding this comment.
Since this is the only virtual method and it delegates to _requestHandler we can make this class sealed and pass delegate from AgentWebSocketServer (i.e. not inherit AgentWebSocketServer from KestrelWebSocketServer).
Then can potentially also merge AgentWebSocketServer into WebSocketClientTransport
| string? decryptedSecret; | ||
| try | ||
| { | ||
| decryptedSecret = _sharedSecretProvider.DecryptSecret(Base64Url.DecodeToStandardBase64(subProtocol)); |
There was a problem hiding this comment.
We're using WebUtility.UrlDecode in AbstractBrowserRefreshServer.OnBrowserConnected to decode the secret
| return; | ||
| } | ||
|
|
||
| if (string.IsNullOrEmpty(decryptedSecret)) |
There was a problem hiding this comment.
We already checked string.IsNullOrEmpty(subProtocol) above, the decrypted secret shouldn't be empty, right?
| protected override async Task HandleRequestAsync(HttpContext context) | ||
| { | ||
| // Validate the shared secret from the subprotocol | ||
| if (context.WebSockets.WebSocketRequestedProtocols is not [var subProtocol] || string.IsNullOrEmpty(subProtocol)) |
There was a problem hiding this comment.
Wouldn't subProtocol ne null for non-secure connection?
| // passed via `dotnet run -e` as @(RuntimeEnvironmentVariable) items. | ||
| => new(new HotReloadClients(new MobileHotReloadClient(clientLogger, agentLogger, context.EnvironmentOptions.HotReloadHttpPort, GetStartupHookPath(project)), browserRefreshServer: null)); | ||
| => new(new HotReloadClients( | ||
| new DefaultHotReloadClient(clientLogger, agentLogger, GetStartupHookPath(project), enableStaticAssetUpdates: false, |
There was a problem hiding this comment.
enableStaticAssetUpdates should be true, no? Don't we want to send over CSS updates? The same as Windows MAUI app.
|
|
||
| App.Start(testAsset, []); | ||
|
|
||
| await App.WaitForOutputLineContaining("Started"); |
There was a problem hiding this comment.
We should use App.WaitUntileOutputContains to avoid race conditions between the process output and watch output.
There was a problem hiding this comment.
You can use await App.WaitUntileOutputContains instead of App.AssertOutputContains as well, unless you particularly care about the order in which the messages appear.
Context: #52492
dotnet watchfor .NET MAUI ScenariosOverview
This implements
dotnet watch/ Hot Reload for mobile platforms (Android, iOS), which cannot use the standard named pipe transport. Similar to how web applications already use websockets for reloading CSS and JavaScript, we will use the same model for mobile applications.Transport Selection
adb reversetunnels the connectiondotnet-watchdetects WebSocket transport via theHotReloadWebSocketscapability:Mobile workloads (Android, iOS) add this capability to their SDK targets. This allows any workload to opt into WebSocket-based hot reload.
SDK Changes
WebSocket Details
dotnet-watchalready has a WebSocket server for web apps:BrowserRefreshServer. This server:https://localhost:<port>aspnetcore-browser-refresh.js) injected into web pagesFor mobile, we reuse the Kestrel infrastructure but with a different protocol:
BrowserRefreshServerHotReloadWebSocketServerThe mobile server (
HotReloadWebSocketServer) extendsKestrelWebSocketServerand speaks the same binary protocol as the named pipe transport, just over WebSocket instead.1. WebSocket Capability Detection
ProjectGraphUtilities.cschecks for theHotReloadWebSocketscapability (case-insensitive).2. MobileAppModel
Creates a
MobileHotReloadClientwith a WebSocket server instead of named pipes.3. Environment Variables
dotnet-watchlaunches the app via:The port is dynamically assigned (defaults to 0, meaning the OS picks an available port) to avoid conflicts in CI and parallel test scenarios. The
DOTNET_WATCH_HOTRELOAD_HTTP_PORTenvironment variable can override this if a specific port is needed.These environment variables are passed as
@(RuntimeEnvironmentVariable)MSBuild items to the workload. Seedotnet-run-for-maui.mdfor details ondotnet runand environment variables.Android Workload Changes (Example Integration)
dotnet/android#10770 — RuntimeEnvironmentVariable Support
Enables the Android workload to receive env vars from
dotnet run -e:<ProjectCapability Include="RuntimeEnvironmentVariableSupport" />@(RuntimeEnvironmentVariable)items, so they will apply to Android.dotnet/android#10778 — dotnet-watch Integration
DOTNET_STARTUP_HOOKS, includes the assembly in the app package, rewrites the path to just the assembly name (since the full path doesn't exist on device)adb reverse tcp:<port> tcp:<port>so the device can reach the host's WebSocket server via127.0.0.1:<port>(port is parsed from the endpoint URL)Microsoft.Android.Run(the desktop launcher) so only the mobile app connects<ProjectCapability Include="HotReloadWebSockets" />to opt into WebSocket-based hot reload.Data Flow
dotnet-watchbuilds the project, detectsHotReloadWebSocketscapabilitydotnet run -e DOTNET_WATCH_HOTRELOAD_WEBSOCKET_ENDPOINT=ws://127.0.0.1:<port> -e DOTNET_STARTUP_HOOKS=...Transport.TryCreate()reads env vars →WebSocketTransportconnects tows://127.0.0.1:<port>iOS
Similar changes will be made in the iOS workload to opt into WebSocket-based hot reload:
<ProjectCapability Include="HotReloadWebSockets" />Dependencies
$DOTNET_STARTUP_HOOKS— needed for Mono runtime to honor startup hooks (temporary workaround viaRuntimeHostConfigurationOption)