Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/Microsoft.Android.Run/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@

try {
return await RunAsync (args);
} catch (OperationCanceledException) {
return 130; // 128 + SIGINT(2), standard Unix convention for Ctrl+C
Comment thread
jonathanpeppers marked this conversation as resolved.
} catch (Exception ex) {
Console.Error.WriteLine ($"Error: {ex.Message}");
if (verbose)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ public DotNetCLI (string projectOrSolution)
/// Creates and starts a `dotnet` process with the specified arguments.
/// </summary>
/// <param name="args">command arguments</param>
/// <param name="workingDirectory">optional working directory</param>
/// <returns>A started Process instance. Caller is responsible for disposing.</returns>
protected Process ExecuteProcess (string [] args, string workingDirectory = null)
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Threading;
Comment thread
jonathanpeppers marked this conversation as resolved.
using NUnit.Framework;

Expand Down Expand Up @@ -32,5 +34,64 @@ public static void SetEnvironmentVariable (this ProcessStartInfo psi, string key

Assert.Inconclusive ("Could not set ProcessStartInfo environment variable.");
}

/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// See dotnet/sdk's NativeMethods.cs and GivenDotnetRunIsInterrupted.cs for the pattern used here.
/// </remarks>
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<int> ();
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}");
}
}
}
Comment thread
jonathanpeppers marked this conversation as resolved.
}

static void GetDescendantPids (int parentPid, List<int> 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;
}
}
99 changes: 99 additions & 0 deletions tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
jonathanpeppers marked this conversation as resolved.

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 ();
}
Comment thread
jonathanpeppers marked this conversation as resolved.

File.WriteAllText (logPath, output.ToString ());
TestContext.AddTestAttachment (logPath);
}
}

[Test]
public void DotNetRunWithDeviceParameter ()
{
Expand Down