Skip to content
36 changes: 34 additions & 2 deletions src/Cli/Microsoft.DotNet.Cli.Utils/ProcessReaper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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;
Expand Down
53 changes: 53 additions & 0 deletions test/TestAssets/TestProjects/WinExeApp/Program.cs
Original file line number Diff line number Diff line change
@@ -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;

// <metadata update handler placeholder>

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})");
}
}
12 changes: 12 additions & 0 deletions test/TestAssets/TestProjects/WinExeApp/WinExeApp.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>$(CurrentTargetFramework)-windows</TargetFramework>
<OutputType>WinExe</OutputType>
<UseWindowsForms>true</UseWindowsForms>
<ImplicitUsings>enable</ImplicitUsings>
<!-- properties placeholder -->
</PropertyGroup>
<ItemGroup>
<!-- items placeholder -->
</ItemGroup>
</Project>
27 changes: 27 additions & 0 deletions test/dotnet-watch.Tests/HotReload/TerminationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)");
}
}
}
79 changes: 79 additions & 0 deletions test/dotnet.Tests/CommandTests/Run/GivenDotnetRunIsInterrupted.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
#nullable disable

using System.Diagnostics;
using System.Runtime.InteropServices;
using Microsoft.DotNet.Cli.Utils;
using Xunit.Sdk;

Expand All @@ -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")]
Expand Down