Skip to content

[dotnet watch] fix deadlock on iOS with UIKitSynchronizationContext#54023

Merged
jonathanpeppers merged 1 commit intorelease/10.0.4xxfrom
dev/peppers/watch-websocket-configureawait
Apr 23, 2026
Merged

[dotnet watch] fix deadlock on iOS with UIKitSynchronizationContext#54023
jonathanpeppers merged 1 commit intorelease/10.0.4xxfrom
dev/peppers/watch-websocket-configureawait

Conversation

@jonathanpeppers
Copy link
Copy Markdown
Member

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:

image

…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>
@jonathanpeppers jonathanpeppers requested review from a team and tmat as code owners April 21, 2026 19:48
Copilot AI review requested due to automatic review settings April 21, 2026 19:48
Copy link
Copy Markdown
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 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 within Listener.
  • Add ConfigureAwait(false) to awaited operations in WebSocketTransport send/receive/connect paths to avoid resuming onto the UI SynchronizationContext.

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 jonathanpeppers merged commit 49fcff8 into release/10.0.4xx Apr 23, 2026
32 checks passed
@jonathanpeppers jonathanpeppers deleted the dev/peppers/watch-websocket-configureawait branch April 23, 2026 22:01
@tmat
Copy link
Copy Markdown
Member

tmat commented Apr 24, 2026

@jonathanpeppers We should enable analyzer warnings that report missing calls to ConfigureAwait on this project.

@tmat
Copy link
Copy Markdown
Member

tmat commented Apr 24, 2026

On iOS with CoreCLR, UIKitSynchronizationContext is installed before startup hooks run.

Do you know where is this installed? It's odd that any app specific code is executed before the startup hook.

@jonathanpeppers
Copy link
Copy Markdown
Member Author

We could ask @rolfbjarne specifics, but the code is:

I added a log message that printed out the UIKitSynchronizationContext object was there, inside the startup hook.

Copilot is showing the ordering for the two runtimes Mono/CoreCLR:

  1. xamarin_vm_initialize() — start VM (STARTUP_HOOKS registered as property)
  2. xamarin_initialize() — calls managed Runtime.Initialize()
  3. → UIKitSynchronizationContext installed
  4. mono_jit_exec / coreclr_execute_assembly — runs startup hooks, then Main()

@rolfbjarne
Copy link
Copy Markdown
Member

@jonathanpeppers is correct, that's the ordering and exactly what's happening.

jonathanpeppers added a commit that referenced this pull request Apr 27, 2026
…#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>
jonathanpeppers added a commit that referenced this pull request Apr 27, 2026
…#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>
@tmat
Copy link
Copy Markdown
Member

tmat commented Apr 29, 2026

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

@rolfbjarne
Copy link
Copy Markdown
Member

@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 [UnmanagedCallersOnly] method directly from native code. Using a startup hook might also have an ordering issue with other startup hooks (which one would be executed first?). Also startup hooks aren't trimmer safe.

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).

jonathanpeppers added a commit that referenced this pull request Apr 30, 2026
…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>
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