From c3fb26079231ed71bb31601148f89200bfb93a29 Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Tue, 5 May 2026 14:35:06 -0500 Subject: [PATCH] [tests] Add DotNetRunCtrlC integration test Add a device integration test that verifies Ctrl+C (SIGINT) sent to `dotnet run` properly stops the Android app on the device/emulator. The test: 1. Builds and launches an Android app via `dotnet run` with WaitForExit 2. Waits for app logcat output confirming launch 3. Verifies the app PID exists on the device 4. Sends SIGINT via Process.SendCtrlC() extension method 5. Asserts the process exits gracefully 6. Verifies Microsoft.Android.Run's Ctrl+C handler ran ("Stopping application...") 7. Confirms the app is no longer running on the device Also adds a `SendCtrlC()` extension method on `Process` in `ProcessExtensions.cs`, using libc `kill(pid, SIGINT)` P/Invoke. Windows support is not yet implemented (throws PlatformNotSupportedException). Fixes: https://github.com/dotnet/android/issues/11264 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Xamarin.ProjectTools/Common/DotNetCLI.cs | 1 + .../Utilities/ProcessExtensions.cs | 25 ++++++ .../Tests/InstallAndRunTests.cs | 90 +++++++++++++++++++ 3 files changed, 116 insertions(+) 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 () {