From 61687757473c8c89f2cbb8e6c5c8529e8adc0c04 Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Wed, 20 May 2026 15:10:12 -0500 Subject: [PATCH] [dotnet watch] Fix device prompt hang for MAUI workload projects When a multi-TFM project uses conditionally-imported workload targets (e.g., MAUI), the ComputeAvailableDevices target is only available in TFM-specific inner builds. The previous code checked the outer project (rootProject.Targets) which has no TargetFramework set, so the conditional import never fires and device selection was skipped. This caused the child dotnet-run process to show its own device prompt, creating a stdin race with dotnet-watch's Console.ReadKey() loop that made the prompt hang. Fix: after TFM selection, look up the TFM-specific inner project node from the ProjectGraph and check its targets for ComputeAvailableDevices. Added DotnetRunDevicesWorkload test asset that simulates MAUI workload behavior with a conditionally-imported Devices.targets file. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Watch/HotReload/HotReloadDotNetWatcher.cs | 39 +++++---- .../DotnetRunDevicesWorkload/Devices.targets | 19 +++++ .../DotnetRunDevicesWorkload.csproj | 44 ++++++++++ .../DotnetRunDevicesWorkload/Program.cs | 21 +++++ .../BuildParametersSelectionPromptTests.cs | 25 ++++++ .../HotReload/MauiHotReloadTests.cs | 84 +++++++++++++++++++ 6 files changed, 216 insertions(+), 16 deletions(-) create mode 100644 test/TestAssets/TestProjects/DotnetRunDevicesWorkload/Devices.targets create mode 100644 test/TestAssets/TestProjects/DotnetRunDevicesWorkload/DotnetRunDevicesWorkload.csproj create mode 100644 test/TestAssets/TestProjects/DotnetRunDevicesWorkload/Program.cs diff --git a/src/Dotnet.Watch/Watch/HotReload/HotReloadDotNetWatcher.cs b/src/Dotnet.Watch/Watch/HotReload/HotReloadDotNetWatcher.cs index d2b8bad2d90b..91ae3f2a609e 100644 --- a/src/Dotnet.Watch/Watch/HotReload/HotReloadDotNetWatcher.cs +++ b/src/Dotnet.Watch/Watch/HotReload/HotReloadDotNetWatcher.cs @@ -1073,29 +1073,36 @@ async ValueTask BuildWithFrameworkAndDeviceSelectionAsync() } } - // Select device if needed: - if (needsDeviceSelection - && rootProject.Targets.ContainsKey(TargetNames.ComputeAvailableDevices)) + // Select device if needed. + // Check the TFM-specific inner project node for ComputeAvailableDevices, since workload + // targets (e.g., MAUI) may only be imported when TargetFramework is set. + if (needsDeviceSelection) { Debug.Assert(deviceSelector != null); + Debug.Assert(projectGraph != null); - var deviceInfo = await TrySelectDeviceAsync(projectGraph, rootProject, targetFramework, deviceSelector, cancellationToken); - if (deviceInfo == null) + var projectNodeForDeviceCheck = projectGraph.TryGetProjectNode(rootProject.FullPath, targetFramework); + if (projectNodeForDeviceCheck != null + && projectNodeForDeviceCheck.ProjectInstance.Targets.ContainsKey(TargetNames.ComputeAvailableDevices)) { - return false; - } + var deviceInfo = await TrySelectDeviceAsync(projectGraph, rootProject, targetFramework, deviceSelector, cancellationToken); + if (deviceInfo == null) + { + return false; + } - selectedDevice = deviceInfo.Id; - selectedDeviceRuntimeIdentifier = deviceInfo.RuntimeIdentifier; - _context.Logger.LogDebug("Selected device: {DeviceId}", selectedDevice); + selectedDevice = deviceInfo.Id; + selectedDeviceRuntimeIdentifier = deviceInfo.RuntimeIdentifier; + _context.Logger.LogDebug("Selected device: {DeviceId}", selectedDevice); - // If the device provides a RuntimeIdentifier, re-restore so the assets file - // includes the RID target. This mirrors the dotnet-run behavior. - if (!string.IsNullOrEmpty(selectedDeviceRuntimeIdentifier)) - { - if (!await BuildAsync(BuildAction.RestoreOnly, targetFramework, deviceInfo)) + // If the device provides a RuntimeIdentifier, re-restore so the assets file + // includes the RID target. This mirrors the dotnet-run behavior. + if (!string.IsNullOrEmpty(selectedDeviceRuntimeIdentifier)) { - return false; + if (!await BuildAsync(BuildAction.RestoreOnly, targetFramework, deviceInfo)) + { + return false; + } } } } diff --git a/test/TestAssets/TestProjects/DotnetRunDevicesWorkload/Devices.targets b/test/TestAssets/TestProjects/DotnetRunDevicesWorkload/Devices.targets new file mode 100644 index 000000000000..0a7f0e716417 --- /dev/null +++ b/test/TestAssets/TestProjects/DotnetRunDevicesWorkload/Devices.targets @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/test/TestAssets/TestProjects/DotnetRunDevicesWorkload/DotnetRunDevicesWorkload.csproj b/test/TestAssets/TestProjects/DotnetRunDevicesWorkload/DotnetRunDevicesWorkload.csproj new file mode 100644 index 000000000000..c80e3579ce15 --- /dev/null +++ b/test/TestAssets/TestProjects/DotnetRunDevicesWorkload/DotnetRunDevicesWorkload.csproj @@ -0,0 +1,44 @@ + + + + Exe + net9.0;$(CurrentTargetFramework) + false + + + + + + + + + + + $(IntermediateOutputPath)DeviceInfo.cs + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/TestAssets/TestProjects/DotnetRunDevicesWorkload/Program.cs b/test/TestAssets/TestProjects/DotnetRunDevicesWorkload/Program.cs new file mode 100644 index 000000000000..98662b384587 --- /dev/null +++ b/test/TestAssets/TestProjects/DotnetRunDevicesWorkload/Program.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. + +using System; + +namespace DotNetRunDevicesWorkload +{ + class Program + { + static void Main(string[] args) + { + Console.WriteLine("Hello from multi-targeted app!"); + Console.WriteLine($"Target Framework: {AppContext.TargetFrameworkName}"); + Console.WriteLine($"Runtime: {System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription}"); + + // DeviceInfo class is generated at build time when Device property is set + Console.WriteLine($"Device: {DeviceInfo.Device}"); + Console.WriteLine($"RuntimeIdentifier: {DeviceInfo.RuntimeIdentifier}"); + } + } +} diff --git a/test/dotnet-watch.Tests/HotReload/BuildParametersSelectionPromptTests.cs b/test/dotnet-watch.Tests/HotReload/BuildParametersSelectionPromptTests.cs index 091146408807..90725fd88672 100644 --- a/test/dotnet-watch.Tests/HotReload/BuildParametersSelectionPromptTests.cs +++ b/test/dotnet-watch.Tests/HotReload/BuildParametersSelectionPromptTests.cs @@ -218,4 +218,29 @@ public void FormatDevice_IdOnly() var formatted = SpectreBuildParametersSelectionPrompt.FormatDevice(device); Assert.Equal("device-1", formatted); } + + [Fact] + public async Task SelectsFrameworkThenDevice() + { + var console = new SpectreTestConsole(); + console.Profile.Capabilities.Interactive = true; + + // Push keys for TFM selection (search + enter) + console.Input.PushText("net9.0"); + console.Input.PushKey(ConsoleKey.Enter); + + // Push keys for device selection (search + enter) + console.Input.PushText("Pixel 7 Pro"); + console.Input.PushKey(ConsoleKey.Enter); + + var frameworks = new[] { "net7.0", "net8.0", "net9.0" }; + var devices = CreateTestDevices(); + var prompt = new SpectreBuildParametersSelectionPrompt(console); + + var selectedFramework = await prompt.SelectTargetFrameworkAsync(frameworks, CancellationToken.None); + Assert.Equal("net9.0", selectedFramework); + + var selectedDevice = await prompt.SelectDeviceAsync(devices, CancellationToken.None); + Assert.Equal("0A041FDD400327", selectedDevice.Id); + } } diff --git a/test/dotnet-watch.Tests/HotReload/MauiHotReloadTests.cs b/test/dotnet-watch.Tests/HotReload/MauiHotReloadTests.cs index ab063c6e8cd0..61d565436d0f 100644 --- a/test/dotnet-watch.Tests/HotReload/MauiHotReloadTests.cs +++ b/test/dotnet-watch.Tests/HotReload/MauiHotReloadTests.cs @@ -110,6 +110,90 @@ public async Task SelectsDevice() await App.WaitUntilOutputContains("Device: test-device-1"); } + /// + /// Tests that the device selection prompt works after a TFM selection prompt. + /// This is the scenario reported as hanging on macOS: when PhysicalConsole's + /// KeyPressed channel is used for the first Spectre prompt, the second prompt + /// must also receive key events from the same channel. + /// + [Fact] + public async Task SelectsFrameworkThenDevice() + { + var testAsset = TestAssets.CopyTestAsset("DotnetRunDevices") + .WithSource(); + + var tfm = ToolsetInfo.CurrentTargetFramework; + + // Start watch WITHOUT --framework so both TFM and device prompts appear. + App.Start(testAsset, [], testFlags: TestFlags.ReadKeyFromStdin); + + // First prompt: select target framework + await App.WaitUntilOutputContains(Resources.SelectTargetFrameworkPrompt); + + foreach (var c in tfm) + { + App.SendKey(c); + } + App.SendKey('\r'); + + // Second prompt: select device (this is the one that hangs without the fix) + await App.WaitUntilOutputContains(Resources.SelectDevicePrompt); + + foreach (var c in "test-device-1") + { + App.SendKey(c); + } + App.SendKey('\r'); + + // The app should launch and print the selected device + await App.WaitUntilOutputContains("Device: test-device-1"); + } + + /// + /// Simulates the MAUI scenario where ComputeAvailableDevices is only available + /// for TFM-specific inner builds (imported conditionally by workload targets). + /// When dotnet-watch loads the project graph with null TFM, the outer project + /// doesn't have the target, so device selection falls through to the child + /// dotnet-run process. This causes a stdin race: dotnet-watch's Console.ReadKey() + /// loop steals all key presses from the child process's device prompt. + /// + [Fact] + public async Task SelectsFrameworkThenDevice_WorkloadConditionalTarget() + { + var testAsset = TestAssets.CopyTestAsset("DotnetRunDevicesWorkload") + .WithSource(); + + var tfm = ToolsetInfo.CurrentTargetFramework; + + // Start watch WITHOUT --framework so both TFM and device prompts appear. + App.Start(testAsset, [], testFlags: TestFlags.ReadKeyFromStdin); + + // First prompt: select target framework + await App.WaitUntilOutputContains(Resources.SelectTargetFrameworkPrompt); + + foreach (var c in tfm) + { + App.SendKey(c); + } + App.SendKey('\r'); + + // Second prompt: select device. + // With the bug, dotnet-watch doesn't see ComputeAvailableDevices in the outer + // project, skips device selection, and launches the child dotnet-run which shows + // its own device prompt. The child's stdin competes with dotnet-watch's + // Console.ReadKey() loop, causing the prompt to hang. + await App.WaitUntilOutputContains(Resources.SelectDevicePrompt); + + foreach (var c in "test-device-1") + { + App.SendKey(c); + } + App.SendKey('\r'); + + // The app should launch and print the selected device + await App.WaitUntilOutputContains("Device: test-device-1"); + } + [Fact] public async Task AutoSelectsSingleDevice() {