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 () {