[dotnet watch] fix deadlock on iOS with UIKitSynchronizationContext#54023
Conversation
…text On iOS with CoreCLR, UIKitSynchronizationContext is installed before startup hooks run. Listener.Listen() calls GetAwaiter().GetResult() on the main thread, and await continuations try to post back to the blocked UI thread, causing a deadlock. Fix by adding ConfigureAwait(false) to awaits in the startup hook's call chain. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This PR addresses a deadlock in dotnet watch Hot Reload on iOS/CoreCLR caused by UIKitSynchronizationContext being installed before startup hooks run, combined with synchronous blocking (GetAwaiter().GetResult()) during startup-hook initialization.
Changes:
- Add
ConfigureAwait(false)to awaited operations in the startup-hook initialization call chain withinListener. - Add
ConfigureAwait(false)to awaited operations inWebSocketTransportsend/receive/connect paths to avoid resuming onto the UISynchronizationContext.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
| src/Dotnet.Watch/HotReloadAgent.Host/WebSocketTransport.cs | Prevents WebSocket connect/send/receive continuations from attempting to marshal back to a potentially-blocked UI thread. |
| src/Dotnet.Watch/HotReloadAgent.Host/Listener.cs | Prevents initialization/update-processing continuations from attempting to resume on the UI SynchronizationContext during the synchronous startup-hook initialization phase. |
|
@jonathanpeppers We should enable analyzer warnings that report missing calls to ConfigureAwait on this project. |
Do you know where is this installed? It's odd that any app specific code is executed before the startup hook. |
|
We could ask @rolfbjarne specifics, but the code is: I added a log message that printed out the Copilot is showing the ordering for the two runtimes Mono/CoreCLR:
|
|
@jonathanpeppers is correct, that's the ordering and exactly what's happening. |
…#54023) On iOS with CoreCLR, `UIKitSynchronizationContext` is installed before startup hooks run. `Listener.Listen()` calls `GetAwaiter().GetResult()` on the main thread, and await continuations try to post back to the blocked UI thread, causing a deadlock. Fix by adding `ConfigureAwait(false)` to awaits in the startup hook's call chain. On Android, just due to startup ordering the `SynchronizationContext` was not set. I didn't think of a way we could test this easily -- I basically built the dotnet/macios repo and copied dotnet-watch files on top to manually test. An end-to-end test in the dotnet/macios repo might be the best place. With these changes in place: ``` 2026-04-21 14:41:01.681990-0500 heyo[27489:980549] [HotReload] DOTNET_WATCH_HOTRELOAD_WEBSOCKET_ENDPOINT=ws://localhost:61875 2026-04-21 14:41:01.686900-0500 heyo[27489:980549] [HotReload] Connecting to Hot Reload server via WebSocket ws://localhost:61875. 2026-04-21 14:41:01.696114-0500 heyo[27489:980549] [HotReload] Connecting to ws://localhost:61875... dotnet watch 🔥 [heyo (net11.0-ios)] WebSocket client connected 2026-04-21 14:41:01.754571-0500 heyo[27489:980834] [HotReload] Connected. 2026-04-21 14:41:01.755569-0500 heyo[27489:980834] [HotReload] Sending InitializationResponse (247 bytes) dotnet watch 🔥 [heyo (net11.0-ios)] Capabilities: 'Baseline AddMethodToExistingType AddStaticFieldToExistingType AddInstanceFieldToExistingType NewTypeDefinition ChangeCustomAttributes UpdateParameters GenericUpdateMethod GenericAddMethodToExistingType GenericAddFieldToExistingType AddFieldRva AddExplicitInterfaceImplementation'. 2026-04-21 14:41:01.768519-0500 heyo[27489:980834] [HotReload] Received 1 bytes dotnet watch ⌚ Waiting for changes dotnet watch ⌚ File change: Update '/Users/jopeppers/src/heyo/SceneDelegate.cs'. dotnet watch ⌚ File updated: ./SceneDelegate.cs dotnet watch ⌚ Updating document text of '/Users/jopeppers/src/heyo/SceneDelegate.cs'. dotnet watch ⌚ Solution after document update: v2 dotnet watch 🔥 Hot reload capabilities: AddExplicitInterfaceImplementation AddFieldRva AddInstanceFieldToExistingType AddMethodToExistingType AddStaticFieldToExistingType Baseline ChangeCustomAttributes GenericAddFieldToExistingType GenericAddMethodToExistingType GenericUpdateMethod NewTypeDefinition UpdateParameters. dotnet watch 🔥 [heyo (net11.0-ios)] Sending update batch #0 2026-04-21 14:41:13.296225-0500 heyo[27489:980834] [HotReload] Received 5770 bytes 2026-04-21 14:41:13.338366-0500 heyo[27489:980834] [HotReload] Sending UpdateResponse (466 bytes) dotnet watch 🕵️ [heyo (net11.0-ios)] Writing capabilities: Baseline AddMethodToExistingType AddStaticFieldToExistingType AddInstanceFieldToExistingType NewTypeDefinition ChangeCustomAttributes UpdateParameters GenericUpdateMethod GenericAddMethodToExistingType GenericAddFieldToExistingType AddFieldRva dotnet watch 🕵️ [heyo (net11.0-ios)] Applying updates to module 24147f53-599d-4c51-872f-f2073583eddb. dotnet watch 🕵️ [heyo (net11.0-ios)] Invoking metadata update handlers. dotnet watch 🕵️ [heyo (net11.0-ios)] System.Reflection.Metadata.RuntimeTypeMetadataUpdateHandler.ClearCache dotnet watch 🕵️ [heyo (net11.0-ios)] Updates applied. dotnet watch 🔥 [heyo (net11.0-ios)] Update batch #0 completed. dotnet watch 🔥 C# and Razor changes applied in 567ms. ``` I can see it working on a net11.0-ios project in an iOS simulator: <img width="480" height="588" alt="image" src="https://github.com/user-attachments/assets/9e5944ff-db11-4912-ae4d-48b53190b1da" /> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…#54023) On iOS with CoreCLR, `UIKitSynchronizationContext` is installed before startup hooks run. `Listener.Listen()` calls `GetAwaiter().GetResult()` on the main thread, and await continuations try to post back to the blocked UI thread, causing a deadlock. Fix by adding `ConfigureAwait(false)` to awaits in the startup hook's call chain. On Android, just due to startup ordering the `SynchronizationContext` was not set. I didn't think of a way we could test this easily -- I basically built the dotnet/macios repo and copied dotnet-watch files on top to manually test. An end-to-end test in the dotnet/macios repo might be the best place. With these changes in place: ``` 2026-04-21 14:41:01.681990-0500 heyo[27489:980549] [HotReload] DOTNET_WATCH_HOTRELOAD_WEBSOCKET_ENDPOINT=ws://localhost:61875 2026-04-21 14:41:01.686900-0500 heyo[27489:980549] [HotReload] Connecting to Hot Reload server via WebSocket ws://localhost:61875. 2026-04-21 14:41:01.696114-0500 heyo[27489:980549] [HotReload] Connecting to ws://localhost:61875... dotnet watch 🔥 [heyo (net11.0-ios)] WebSocket client connected 2026-04-21 14:41:01.754571-0500 heyo[27489:980834] [HotReload] Connected. 2026-04-21 14:41:01.755569-0500 heyo[27489:980834] [HotReload] Sending InitializationResponse (247 bytes) dotnet watch 🔥 [heyo (net11.0-ios)] Capabilities: 'Baseline AddMethodToExistingType AddStaticFieldToExistingType AddInstanceFieldToExistingType NewTypeDefinition ChangeCustomAttributes UpdateParameters GenericUpdateMethod GenericAddMethodToExistingType GenericAddFieldToExistingType AddFieldRva AddExplicitInterfaceImplementation'. 2026-04-21 14:41:01.768519-0500 heyo[27489:980834] [HotReload] Received 1 bytes dotnet watch ⌚ Waiting for changes dotnet watch ⌚ File change: Update '/Users/jopeppers/src/heyo/SceneDelegate.cs'. dotnet watch ⌚ File updated: ./SceneDelegate.cs dotnet watch ⌚ Updating document text of '/Users/jopeppers/src/heyo/SceneDelegate.cs'. dotnet watch ⌚ Solution after document update: v2 dotnet watch 🔥 Hot reload capabilities: AddExplicitInterfaceImplementation AddFieldRva AddInstanceFieldToExistingType AddMethodToExistingType AddStaticFieldToExistingType Baseline ChangeCustomAttributes GenericAddFieldToExistingType GenericAddMethodToExistingType GenericUpdateMethod NewTypeDefinition UpdateParameters. dotnet watch 🔥 [heyo (net11.0-ios)] Sending update batch #0 2026-04-21 14:41:13.296225-0500 heyo[27489:980834] [HotReload] Received 5770 bytes 2026-04-21 14:41:13.338366-0500 heyo[27489:980834] [HotReload] Sending UpdateResponse (466 bytes) dotnet watch 🕵️ [heyo (net11.0-ios)] Writing capabilities: Baseline AddMethodToExistingType AddStaticFieldToExistingType AddInstanceFieldToExistingType NewTypeDefinition ChangeCustomAttributes UpdateParameters GenericUpdateMethod GenericAddMethodToExistingType GenericAddFieldToExistingType AddFieldRva dotnet watch 🕵️ [heyo (net11.0-ios)] Applying updates to module 24147f53-599d-4c51-872f-f2073583eddb. dotnet watch 🕵️ [heyo (net11.0-ios)] Invoking metadata update handlers. dotnet watch 🕵️ [heyo (net11.0-ios)] System.Reflection.Metadata.RuntimeTypeMetadataUpdateHandler.ClearCache dotnet watch 🕵️ [heyo (net11.0-ios)] Updates applied. dotnet watch 🔥 [heyo (net11.0-ios)] Update batch #0 completed. dotnet watch 🔥 C# and Razor changes applied in 567ms. ``` I can see it working on a net11.0-ios project in an iOS simulator: <img width="480" height="588" alt="image" src="https://github.com/user-attachments/assets/9e5944ff-db11-4912-ae4d-48b53190b1da" /> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
@rolfbjarne Would it make sense to call startup hooks before the UIApplication code? I'd expect all application framework code to run after the startup hooks. |
We need to initialize some things (in managed code) before any user code is run. We don't have control over the first user code that will be executed (it's typically the managed Main method, but might not be in some cases), so we can't just do something like injecting initialization code into the managed Main method (which wouldn't be reliable anyway, due to static or library cctors). We could use a startup hook, but that's a lot of unnecessary machinery when we can just call a managed Another problem if we initialize our stuff after running startup hooks is that if user code in a startup hook tries to use the API we (the iOS workload, not the BCL) expose - things will very likely break (there's a reason we need to initialize stuff before running user code, and in this case user code would run before we initialized that stuff). |
…ly (#54116) Enable CA2007 as an error in the `HotReloadAgent.Host` `.editorconfig` and add `ConfigureAwait(false)` to awaits in the startup hook assembly. This assembly runs inside the user's app process where a `SynchronizationContext` may be present (e.g. `UIKitSynchronizationContext` on iOS), so missing `ConfigureAwait(false)` can cause deadlocks. Context: #54023 (comment) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
On iOS with CoreCLR,
UIKitSynchronizationContextis installed before startup hooks run.Listener.Listen()callsGetAwaiter().GetResult()on the main thread, and await continuations try to post back to the blocked UI thread, causing a deadlock. Fix by addingConfigureAwait(false)to awaits in the startup hook's call chain. On Android, just due to startup ordering theSynchronizationContextwas not set.I didn't think of a way we could test this easily -- I basically built the dotnet/macios repo and copied dotnet-watch files on top to manually test. An end-to-end test in the dotnet/macios repo might be the best place.
With these changes in place:
I can see it working on a net11.0-ios project in an iOS simulator: