diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Common/DotNetCLI.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Common/DotNetCLI.cs
index 47e5b4f90d1..3f1c4c1bb57 100644
--- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Common/DotNetCLI.cs
+++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Common/DotNetCLI.cs
@@ -31,6 +31,7 @@ public DotNetCLI (string projectOrSolution)
/// Creates and starts a `dotnet` process with the specified arguments.
///
/// command arguments
+ /// optional working directory
/// A started Process instance. Caller is responsible for disposing.
protected Process ExecuteProcess (string [] args, string workingDirectory = null)
{
diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Utilities/ProcessExtensions.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Utilities/ProcessExtensions.cs
index ec0b4f1e35f..eb2b155b069 100644
--- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Utilities/ProcessExtensions.cs
+++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Utilities/ProcessExtensions.cs
@@ -1,5 +1,6 @@
using System;
using System.Diagnostics;
+using System.Runtime.InteropServices;
using System.Threading;
using NUnit.Framework;
@@ -32,5 +33,29 @@ public static void SetEnvironmentVariable (this ProcessStartInfo psi, string key
Assert.Inconclusive ("Could not set ProcessStartInfo environment variable.");
}
+
+ ///
+ /// Sends Ctrl+C (SIGINT) to the specified process.
+ /// Currently only supported on Unix/macOS; throws PlatformNotSupportedException on Windows.
+ ///
+ ///
+ /// See dotnet/sdk's NativeMethods.cs and GivenDotnetRunIsInterrupted.cs for the pattern used here.
+ ///
+ public static void SendCtrlC (this Process process)
+ {
+ if (OperatingSystem.IsWindows ()) {
+ throw new PlatformNotSupportedException ("SendCtrlC is not yet implemented on Windows.");
+ }
+ int result = kill (process.Id, SIGINT);
+ if (result != 0) {
+ throw new InvalidOperationException (
+ $"kill({process.Id}, SIGINT) failed with errno {Marshal.GetLastPInvokeError ()}");
+ }
+ }
+
+ [DllImport ("libc", SetLastError = true)]
+ static extern int kill (int pid, int sig);
+
+ const int SIGINT = 2;
}
}
diff --git a/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs b/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs
index f178cda5a5a..007d2be1218 100644
--- a/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs
+++ b/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs
@@ -175,6 +175,96 @@ public void DotNetRunWaitForExit ()
Assert.IsTrue (foundMessage, $"Expected message '{logcatMessage}' was not found in output. See {logPath} for details.");
}
+ [Test]
+ public void DotNetRunCtrlC ()
+ {
+ AssertCommercialBuild (); //FIXME: https://github.com/dotnet/android/issues/10832
+
+ const string logcatMessage = "DOTNET_RUN_CTRLC_TEST_99999";
+ var proj = new XamarinAndroidApplicationProject ();
+
+ // Enable verbose output from Microsoft.Android.Run for debugging
+ proj.SetProperty ("_AndroidRunExtraArgs", "--verbose");
+
+ // Add a Console.WriteLine that will appear in logcat
+ proj.MainActivity = proj.DefaultMainActivity.Replace (
+ "//${AFTER_ONCREATE}",
+ $"Console.WriteLine (\"{logcatMessage}\");");
+
+ using var builder = CreateApkBuilder ();
+ builder.Save (proj);
+
+ var dotnet = new DotNetCLI (Path.Combine (Root, builder.ProjectDirectory, proj.ProjectFilePath));
+ Assert.IsTrue (dotnet.Build (), "`dotnet build` should succeed");
+
+ // Start dotnet run with WaitForExit=true, which uses Microsoft.Android.Run
+ using var process = dotnet.StartRun ();
+
+ var locker = new Lock ();
+ var output = new StringBuilder ();
+ var appLaunched = new ManualResetEventSlim (false);
+
+ process.OutputDataReceived += (sender, e) => {
+ if (e.Data != null) {
+ lock (locker) {
+ output.AppendLine (e.Data);
+ if (e.Data.Contains (logcatMessage)) {
+ appLaunched.Set ();
+ }
+ }
+ }
+ };
+ process.ErrorDataReceived += (sender, e) => {
+ if (e.Data != null) {
+ lock (locker) {
+ output.AppendLine ($"STDERR: {e.Data}");
+ }
+ }
+ };
+
+ process.BeginOutputReadLine ();
+ process.BeginErrorReadLine ();
+
+ // Wait for the app to start and produce logcat output
+ bool launched = appLaunched.Wait (TimeSpan.FromSeconds (ActivityStartTimeoutInSeconds));
+
+ string logPath = Path.Combine (Root, builder.ProjectDirectory, "dotnet-run-ctrlc-output.log");
+ try {
+ Assert.IsTrue (launched, $"Expected message '{logcatMessage}' was not found in output within {ActivityStartTimeoutInSeconds}s.");
+
+ // Verify the app is running on the device
+ var pidOutput = RunAdbCommand ($"shell pidof {proj.PackageName}").Trim ();
+ Assert.IsTrue (!string.IsNullOrEmpty (pidOutput) && int.TryParse (pidOutput.Split (' ') [0], out _),
+ $"App should be running on the device. pidof output: '{pidOutput}'");
+
+ // Send Ctrl+C to the dotnet run process
+ process.SendCtrlC ();
+
+ // Wait for the process to exit gracefully
+ bool exited = process.WaitForExit (30_000);
+ Assert.IsTrue (exited, "dotnet run process should have exited after SIGINT");
+
+ // Verify the output contains the "Stopping application..." message from Microsoft.Android.Run
+ string outputText = output.ToString ();
+ Assert.IsTrue (outputText.Contains ("Stopping application..."),
+ $"Output should contain 'Stopping application...' from Microsoft.Android.Run's Ctrl+C handler");
+
+ // Verify the app is no longer running on the device
+ pidOutput = RunAdbCommand ($"shell pidof {proj.PackageName}").Trim ();
+ Assert.IsTrue (string.IsNullOrEmpty (pidOutput),
+ $"App should not be running on the device after Ctrl+C. pidof output: '{pidOutput}'");
+ } finally {
+ // Ensure the process is killed if it's still running
+ if (!process.HasExited) {
+ process.Kill (entireProcessTree: true);
+ process.WaitForExit ();
+ }
+
+ File.WriteAllText (logPath, output.ToString ());
+ TestContext.AddTestAttachment (logPath);
+ }
+ }
+
[Test]
public void DotNetRunWithDeviceParameter ()
{