diff --git a/src/Cli/Microsoft.DotNet.Cli.Utils/ProcessReaper.cs b/src/Cli/Microsoft.DotNet.Cli.Utils/ProcessReaper.cs index 32af1993bf08..b03e3c7d9136 100644 --- a/src/Cli/Microsoft.DotNet.Cli.Utils/ProcessReaper.cs +++ b/src/Cli/Microsoft.DotNet.Cli.Utils/ProcessReaper.cs @@ -33,7 +33,19 @@ public ProcessReaper(Process process) // where the child writes output the test expects before the intermediate dotnet process // has registered the event handlers to handle the signals the tests will generate. Console.CancelKeyPress += HandleCancelKeyPress; - if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + // Ensure Ctrl+C handling is enabled in this process. + // + // When a parent process (e.g. dotnet-watch) launches us with CREATE_NEW_PROCESS_GROUP, + // Ctrl+C handlers are disabled in the new process group. We re-enable them so that + // HandleCancelKeyPress fires when the parent sends CTRL_C_EVENT. + // This is safe to call unconditionally — it's a no-op if Ctrl+C is already enabled. + // + // See https://learn.microsoft.com/windows/console/setconsolectrlhandler + EnableWindowsCtrlCHandling(); + } + else { _shutdownMutex = new Mutex(); AppDomain.CurrentDomain.ProcessExit += HandleProcessExit; @@ -93,10 +105,22 @@ public void Dispose() Console.CancelKeyPress -= HandleCancelKeyPress; } - private static void HandleCancelKeyPress(object? sender, ConsoleCancelEventArgs e) + private void HandleCancelKeyPress(object? sender, ConsoleCancelEventArgs e) { // Ignore SIGINT/SIGQUIT so that the process can handle the signal e.Cancel = true; + + // For WinExe apps (WinForms, WPF, MAUI) that don't respond to Ctrl+C, + // CloseMainWindow() posts WM_CLOSE to gracefully shut them down. + // For console apps this is a no-op (returns false) since they have no main window. + try + { + _process.CloseMainWindow(); + } + catch (InvalidOperationException) + { + // The process hasn't started yet or has already exited; nothing to signal + } } private static SafeWaitHandle? AssignProcessToJobObject(IntPtr process) @@ -186,6 +210,14 @@ private static bool SetKillOnJobClose(IntPtr job, bool value) } } + private static void EnableWindowsCtrlCHandling() + { + SetConsoleCtrlHandler(null, false); + + [DllImport("kernel32.dll", SetLastError = true)] + static extern bool SetConsoleCtrlHandler(Delegate? handler, bool add); + } + private Process _process; private SafeWaitHandle? _job; private Mutex? _shutdownMutex; diff --git a/test/TestAssets/TestProjects/WinExeApp/Program.cs b/test/TestAssets/TestProjects/WinExeApp/Program.cs new file mode 100644 index 000000000000..613bd3a7adf6 --- /dev/null +++ b/test/TestAssets/TestProjects/WinExeApp/Program.cs @@ -0,0 +1,53 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics; +using System.Windows.Forms; + +// + +namespace WinExeApp; + +static class Program +{ + [STAThread] + static int Main() + { + ApplicationConfiguration.Initialize(); + var form = new MainForm(); + Application.Run(form); + + // Return exit code based on how the form was closed + return form.ClosedGracefully ? 0 : 1; + } +} + +class MainForm : Form +{ + public bool ClosedGracefully { get; private set; } + + public MainForm() + { + Text = "WinExeApp"; + FormClosing += OnFormClosing; + Shown += OnShown; + } + + private void OnShown(object sender, EventArgs e) + { + // Print PID so the test can identify us + Console.WriteLine(Process.GetCurrentProcess().Id); + Console.WriteLine("Started"); + } + + private void OnFormClosing(object sender, FormClosingEventArgs e) + { + // Mark that we were closed gracefully (via CloseMainWindow) + ClosedGracefully = e.CloseReason == CloseReason.UserClosing || + e.CloseReason == CloseReason.WindowsShutDown || + e.CloseReason == CloseReason.TaskManagerClosing; + + Console.WriteLine($"Closing gracefully: {ClosedGracefully} (reason: {e.CloseReason})"); + } +} diff --git a/test/TestAssets/TestProjects/WinExeApp/WinExeApp.csproj b/test/TestAssets/TestProjects/WinExeApp/WinExeApp.csproj new file mode 100644 index 000000000000..a7310a01b569 --- /dev/null +++ b/test/TestAssets/TestProjects/WinExeApp/WinExeApp.csproj @@ -0,0 +1,12 @@ + + + $(CurrentTargetFramework)-windows + WinExe + true + enable + + + + + + diff --git a/test/dotnet-watch.Tests/HotReload/TerminationTests.cs b/test/dotnet-watch.Tests/HotReload/TerminationTests.cs index 7fe09eea4085..ae15471e2ebe 100644 --- a/test/dotnet-watch.Tests/HotReload/TerminationTests.cs +++ b/test/dotnet-watch.Tests/HotReload/TerminationTests.cs @@ -70,5 +70,32 @@ public async Task GracefulTermination_Unix() await App.WaitUntilOutputContains("SIGTERM detected! Performing cleanup..."); await App.WaitUntilOutputContains("exited with exit code 0."); } + + [PlatformSpecificFact(TestPlatforms.Windows)] + public async Task GracefulTermination_WinExe() + { + // Test that WinExe apps (WinForms, WPF, MAUI) are terminated gracefully when dotnet-watch + // sends Ctrl+C. The `dotnet run` process receives Ctrl+C and calls CloseMainWindow() on the + // WinForms app. See https://github.com/dotnet/sdk/issues/52473 + + var testAsset = TestAssets.CopyTestAsset("WinExeApp") + .WithSource(); + + App.Start(testAsset, [], testFlags: TestFlags.ReadKeyFromStdin); + + await App.WaitForOutputLineContaining(MessageDescriptor.WaitingForChanges); + + // Wait for the WinForms app to start and show its window + await App.WaitUntilOutputContains("Started"); + + App.SendControlC(); + + // The app should close gracefully via CloseMainWindow + await App.WaitForOutputLineContaining("Closing gracefully: True"); + + // The dotnet run process should exit with code 0, not be force-killed after timeout + await App.WaitUntilOutputContains("exited with exit code 0."); + App.AssertOutputDoesNotContain("(Kill)"); + } } } diff --git a/test/dotnet.Tests/CommandTests/Run/GivenDotnetRunIsInterrupted.cs b/test/dotnet.Tests/CommandTests/Run/GivenDotnetRunIsInterrupted.cs index 2d397f8e0ca2..4944e57c70d0 100644 --- a/test/dotnet.Tests/CommandTests/Run/GivenDotnetRunIsInterrupted.cs +++ b/test/dotnet.Tests/CommandTests/Run/GivenDotnetRunIsInterrupted.cs @@ -4,6 +4,7 @@ #nullable disable using System.Diagnostics; +using System.Runtime.InteropServices; using Microsoft.DotNet.Cli.Utils; using Xunit.Sdk; @@ -17,6 +18,84 @@ public GivenDotnetRunIsInterrupted(ITestOutputHelper log) : base(log) { } + [WindowsOnlyFact] + public void ItTerminatesWinExeAppWithCloseMainWindow() + { + var asset = _testAssetsManager.CopyTestAsset("WinExeApp") + .WithSource(); + + var command = new DotnetCommand(Log, "run") + .WithWorkingDirectory(asset.Path); + + bool signaled = false; + bool sawClosingGracefully = false; + Process child = null; + Process testProcess = null; + command.ProcessStartedHandler = p => { testProcess = p; }; + + command.CommandOutputHandler = line => + { + if (line.StartsWith("\x1b]")) + { + line = line.StripTerminalLoggerProgressIndicators(); + } + + if (line.Contains("Closing gracefully:", StringComparison.Ordinal)) + { + sawClosingGracefully = true; + } + + if (signaled) + { + return; + } + + if (int.TryParse(line, out int pid)) + { + try + { + child = Process.GetProcessById(pid); + } + catch (Exception e) + { + Log.WriteLine($"Error while getting child process Id: {e}"); + Assert.Fail($"Failed to get to child process Id: {line}"); + } + } + else if (line == "Started" && child != null) + { + // Window is now visible, send Ctrl+C to dotnet run process + // dotnet run should detect it's a WinExe app and call CloseMainWindow() on the child + Log.WriteLine($"Sending Ctrl+C to dotnet run process {testProcess.Id}"); + bool sent = GenerateConsoleCtrlEvent(0, (uint)testProcess.Id); + Log.WriteLine($"GenerateConsoleCtrlEvent returned: {sent}"); + signaled = true; + } + else + { + Log.WriteLine($"Got line {line} but was unable to interpret it as a process id - skipping"); + } + }; + + var result = command.Execute(); + signaled.Should().BeTrue("Ctrl+C should have been sent to dotnet run"); + sawClosingGracefully.Should().BeTrue("WinExe app should report graceful close output"); + + // The app should exit with code 0 when closed gracefully via CloseMainWindow + result.ExitCode.Should().Be(0, "WinExe app should exit gracefully when dotnet run receives Ctrl+C and calls CloseMainWindow"); + + Assert.NotNull(child); + if (!child.WaitForExit(WaitTimeout)) + { + child.Kill(); + throw new XunitException("child process failed to terminate."); + } + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + static extern bool GenerateConsoleCtrlEvent(uint dwCtrlEvent, uint dwProcessGroupId); + } + // This test is Unix only for the same reason that CoreFX does not test Console.CancelKeyPress on Windows // See https://github.com/dotnet/corefx/blob/a10890f4ffe0fadf090c922578ba0e606ebdd16c/src/System.Console/tests/CancelKeyPress.Unix.cs#L63-L67 [UnixOnlyFact(Skip = "https://github.com/dotnet/sdk/issues/42841")]