Skip to content

Comments

[dotnet-watch] http transport for mobile#52581

Merged
jonathanpeppers merged 14 commits intodotnet:release/10.0.3xxfrom
jonathanpeppers:dev/peppers/watch-http-transport
Feb 20, 2026
Merged

[dotnet-watch] http transport for mobile#52581
jonathanpeppers merged 14 commits intodotnet:release/10.0.3xxfrom
jonathanpeppers:dev/peppers/watch-http-transport

Conversation

@jonathanpeppers
Copy link
Member

@jonathanpeppers jonathanpeppers commented Jan 20, 2026

Context: #52492

dotnet watch for .NET MAUI Scenarios

Overview

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 use the same model for mobile applications.

Transport Selection

Platform Transport Reason
Desktop/Console Named Pipe Existing implementation, Fast, local IPC
Android/iOS WebSocket Named pipes don't work over the network; adb reverse tunnels the connection

dotnet-watch detects WebSocket transport via the HotReloadWebSockets capability:

<ProjectCapability Include="HotReloadWebSockets" />

Mobile workloads (Android, iOS) add this capability to their SDK targets. This allows any workload to opt into WebSocket-based hot reload.

SDK Changes

Architecture

Both named pipe and WebSocket transports now share a common ClientTransport abstraction consumed by DefaultHotReloadClient:

  • NamedPipeClientTransport — existing local IPC path
  • WebSocketClientTransport — new path for mobile, composes a sealed KestrelWebSocketServer

The agent side mirrors this with a Transport base class (NamedPipeTransport / WebSocketTransport).

WebSocket Details

dotnet-watch already has a WebSocket server for web apps: BrowserRefreshServer. This server:

  • Hosts via Kestrel on a local HTTP or HTTPS endpoint (e.g., http://127.0.0.1:<port> or https://localhost:<port>)
  • Communicates with JavaScript (aspnetcore-browser-refresh.js) injected into web pages
  • Sends commands like "refresh CSS", "reload page", "apply Blazor delta"

For mobile, we reuse the Kestrel infrastructure but with a different protocol:

Server Client Protocol
BrowserRefreshServer JavaScript in browser JSON messages for CSS/page refresh
WebSocketClientTransport Startup hook on device Binary delta payloads (same as named pipe)

WebSocketClientTransport composes a sealed KestrelWebSocketServer (shared with BrowserRefreshServer) and speaks the same binary protocol as the named pipe transport, just over WebSocket instead. Static asset updates (CSS) are also supported.

WebSocket Authentication

To prevent unauthorized processes from connecting to the hot reload server, WebSocketClientTransport uses RSA-based authentication identical to BrowserRefreshServer:

  1. Server generates RSA key pair: SharedSecretProvider creates a 2048-bit RSA key on startup
  2. Public key exported: The public key (X.509 SubjectPublicKeyInfo, Base64-encoded) is passed to the app via DOTNET_WATCH_HOTRELOAD_WEBSOCKET_KEY
  3. Client encrypts secret: The startup hook generates a random 32-byte secret, encrypts it with RSA-OAEP-SHA256 using the public key
  4. Secret sent as subprotocol: The encrypted secret is URL-encoded (WebUtility.UrlEncode) and sent as the WebSocket subprotocol header — same encoding as BrowserRefreshServer
  5. Server validates: WebSocketClientTransport.HandleRequestAsync decodes and decrypts the subprotocol value, accepting the connection only if decryption succeeds

HTTPS Support

KestrelWebSocketServer supports binding both HTTP and HTTPS ports simultaneously via the securePort parameter. When AgentWebSocketSecurePort is configured, the server binds a WSS endpoint alongside the WS endpoint.

Environment Variables

dotnet-watch launches the app via:

dotnet run --no-build \
  -e DOTNET_WATCH=1 \
  -e DOTNET_MODIFIABLE_ASSEMBLIES=debug \
  -e DOTNET_WATCH_HOTRELOAD_WEBSOCKET_ENDPOINT=ws://127.0.0.1:<port> \
  -e DOTNET_WATCH_HOTRELOAD_WEBSOCKET_KEY=<base64-encoded-rsa-public-key> \
  -e DOTNET_STARTUP_HOOKS=<path to DeltaApplier.dll>

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_AGENT_WEBSOCKET_PORT environment variable can override this if a specific port is needed. DOTNET_WATCH_AGENT_WEBSOCKET_SECURE_PORT optionally enables WSS.

These environment variables are passed as @(RuntimeEnvironmentVariable) MSBuild items to the workload. See dotnet-run-for-maui.md for details on dotnet run and environment variables.

Android Workload Changes (Example Integration)

dotnet/android#10770 — RuntimeEnvironmentVariable Support

Enables the Android workload to receive env vars from dotnet run -e:

  • Adds <ProjectCapability Include="RuntimeEnvironmentVariableSupport" />
  • Adds <ProjectCapability Include="HotReloadWebSockets" /> to opt into WebSocket-based hot reload
  • Configures @(RuntimeEnvironmentVariable) items, so they will apply to Android.

dotnet/android#10778 — dotnet-watch Integration

  1. Startup Hook: Parses 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)
  2. Port Forwarding: Runs adb reverse tcp:<port> tcp:<port> so the device can reach the host's WebSocket server via 127.0.0.1:<port> (port is parsed from the endpoint URL)
  3. Prevents Double Connection: Disables startup hooks in Microsoft.Android.Run (the desktop launcher) so only the mobile app connects

Data Flow

  1. Build: dotnet-watch builds the project, detects HotReloadWebSockets capability
  2. Launch: dotnet run -e DOTNET_WATCH_HOTRELOAD_WEBSOCKET_ENDPOINT=ws://127.0.0.1:<port> -e DOTNET_WATCH_HOTRELOAD_WEBSOCKET_KEY=<key> -e DOTNET_STARTUP_HOOKS=...
  3. Workload: Android build tasks:a
    • Include the startup hook DLL in the APK
    • Set up ADB port forwarding for the dynamically assigned port
    • Rewrite env vars for on-device paths
  4. Device: App starts → StartupHook loads → Transport.TryCreate() reads env vars → WebSocketTransport encrypts secret with RSA public key → connects to ws://127.0.0.1:<port> with encrypted secret as subprotocol
  5. Server: WebSocketClientTransport validates the encrypted secret, accepts connection
  6. Hot Reload: File change → delta compiled → sent over WebSocket → applied on device

iOS

Similar changes will be made in the iOS workload to opt into WebSocket-based hot reload:

  • Add <ProjectCapability Include="HotReloadWebSockets" />
  • Handle startup hooks and port forwarding similar to Android

Dependencies

  • runtime#123964: [mono] read $DOTNET_STARTUP_HOOKS — needed for Mono runtime to honor startup hooks (temporary workaround via RuntimeHostConfigurationOption)

jonathanpeppers added a commit to jonathanpeppers/xamarin-android that referenced this pull request Feb 6, 2026
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.
jonathanpeppers added a commit to dotnet/android that referenced this pull request Feb 6, 2026
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.
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

jonathanpeppers and others added 3 commits February 13, 2026 07:50
This was using a HTTP listener at first, later switched to WebSockets.
@jonathanpeppers jonathanpeppers force-pushed the dev/peppers/watch-http-transport branch from 9001e8b to 41e4074 Compare February 13, 2026 13:58
* 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.
@jonathanpeppers jonathanpeppers force-pushed the dev/peppers/watch-http-transport branch from 41e4074 to 9af2d80 Compare February 13, 2026 13:59
…er into WebSocketClientTransport

- Seal KestrelWebSocketServer: remove inheritance, use RequestDelegate, change useTls to int? securePort, make ConvertToWebSocketUrl static
- Delete AgentWebSocketServer: move connection handling and shared secret validation into WebSocketClientTransport directly
- Unify agent protocol: always write ResponseType byte prefix including for InitializationResponse (both NamedPipe and WebSocket)
- Change ClientTransport.WaitForConnectionAsync to return Task (not Task<string>), read init response via ReadAsync in DefaultHotReloadClient
- Refactor NamedPipeClientTransport: create pipe in constructor (non-nullable field), remove Debug.Assert checks
- Add AgentWebSocketSecurePort environment variable and option
- Enable static asset updates for mobile (enableStaticAssetUpdates: true)
- Use pattern matching in Transport.cs (uri.Scheme is not)
- Use WaitUntilOutputContains in MobileHotReloadTests for async assertions
This will throw if anything is invalid, we don't need to use the result.
…ncoding

Align mobile WebSocket auth with BrowserRefreshServer by using
WebUtility.UrlEncode/UrlDecode instead of custom Base64Url encoding.
Delete the now-unused Base64Url helper class.
@tmat
Copy link
Member

tmat commented Feb 20, 2026

Finished another pass. Just a few minor comments.

- WebSocketClientTransport: replace blocking EnsureServerStarted() with
  async static factory method CreateAsync(). Constructor is now private.
- WebSocketClientTransport: invert subprotocol null check to unnest the
  try-catch block in HandleRequestAsync.
- KestrelWebSocketServer: add GetWebSocketUrl(url, hostName?) shared helper
  that both BrowserRefreshServer and WebSocketClientTransport use for
  HTTP-to-WebSocket URL translation (127.0.0.1 -> localhost when no custom
  hostname is set). Renamed from ConvertToWebSocketUrl.
- BrowserRefreshServer: GetServerUrls now delegates to the shared helper.
- MobileAppModel: updated to await the new async factory.
- Test regex updated to match ws://localhost.
@jonathanpeppers jonathanpeppers enabled auto-merge (squash) February 20, 2026 18:59
@tmat
Copy link
Member

tmat commented Feb 20, 2026

/ba-g Linux CI leg is broken.

@jonathanpeppers jonathanpeppers merged commit 888e971 into dotnet:release/10.0.3xx Feb 20, 2026
26 of 28 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants