diff --git a/src/Microsoft.Android.Run/Program.cs b/src/Microsoft.Android.Run/Program.cs
index c7f9cac47e8..2da984571d2 100644
--- a/src/Microsoft.Android.Run/Program.cs
+++ b/src/Microsoft.Android.Run/Program.cs
@@ -21,6 +21,8 @@
try {
return await RunAsync (args);
+} catch (OperationCanceledException) {
+ return 130; // 128 + SIGINT(2), standard Unix convention for Ctrl+C
} catch (Exception ex) {
Console.Error.WriteLine ($"Error: {ex.Message}");
if (verbose)
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..33b6515aa22 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,7 @@
using System;
+using System.Collections.Generic;
using System.Diagnostics;
+using System.Runtime.InteropServices;
using System.Threading;
using NUnit.Framework;
@@ -32,5 +34,64 @@ 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 and all its descendants.
+ /// This simulates what a terminal does on Ctrl+C: send SIGINT to the entire
+ /// foreground process group. Without this, child processes (e.g. Microsoft.Android.Run
+ /// launched by dotnet run) would not receive the signal.
+ /// 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.");
+ }
+
+ // Collect all descendant PIDs first, then send SIGINT to all of them.
+ var pids = new List ();
+ GetDescendantPids (process.Id, pids);
+ pids.Add (process.Id);
+
+ foreach (int pid in pids) {
+ if (kill (pid, SIGINT) != 0) {
+ int errno = Marshal.GetLastPInvokeError ();
+ // ESRCH (3) = process already exited, expected in race conditions
+ if (errno != 3) {
+ Console.Error.WriteLine ($"kill({pid}, SIGINT) failed with errno {errno}");
+ }
+ }
+ }
+ }
+
+ static void GetDescendantPids (int parentPid, List pids)
+ {
+ var psi = new ProcessStartInfo ("pgrep", $"-P {parentPid}") {
+ RedirectStandardOutput = true,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ };
+ using var p = Process.Start (psi);
+ if (p == null) {
+ return;
+ }
+ string output = p.StandardOutput.ReadToEnd ();
+ p.WaitForExit ();
+
+ foreach (string line in output.Split ('\n', StringSplitOptions.RemoveEmptyEntries)) {
+ if (int.TryParse (line.Trim (), out int childPid)) {
+ GetDescendantPids (childPid, pids);
+ pids.Add (childPid);
+ }
+ }
+ }
+
+ [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..7f88cfe0caf 100644
--- a/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs
+++ b/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs
@@ -175,6 +175,105 @@ 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.
+ // Poll with retries since StopAppAsync is fire-and-forget in the Ctrl+C handler.
+ bool appStopped = false;
+ for (int i = 0; i < 10; i++) {
+ pidOutput = RunAdbCommand ($"shell pidof {proj.PackageName}").Trim ();
+ if (string.IsNullOrEmpty (pidOutput)) {
+ appStopped = true;
+ break;
+ }
+ Thread.Sleep (1000);
+ }
+ Assert.IsTrue (appStopped,
+ $"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 ()
{