diff --git a/src/Microsoft.Android.Run/Program.cs b/src/Microsoft.Android.Run/Program.cs index e0366f92066..a9064864b5f 100644 --- a/src/Microsoft.Android.Run/Program.cs +++ b/src/Microsoft.Android.Run/Program.cs @@ -6,6 +6,8 @@ const string Name = "Microsoft.Android.Run"; const string VersionsFileName = "Microsoft.Android.versions.txt"; +const int CtrlCExitCode = 130; // Standard Unix exit code for SIGINT: 128 + signal 2. +const int StopAppTimeoutSeconds = 10; string? adbPath = null; string? adbTarget = null; @@ -17,6 +19,7 @@ int? logcatPid = null; Process? logcatProcess = null; CancellationTokenSource cts = new (); +int ctrlCRequested = 0; string? logcatArgs = null; bool isDotnetTestMode = false; string? dotnetTestPipe = null; @@ -24,7 +27,7 @@ try { return await RunAsync (args); } catch (OperationCanceledException) { - return 130; // 128 + SIGINT(2), standard Unix convention for Ctrl+C + return CtrlCExitCode; } catch (Exception ex) { Console.Error.WriteLine ($"Error: {ex.Message}"); if (verbose) @@ -185,18 +188,24 @@ async Task RunAsync (string[] args) // Set up Ctrl+C handler Console.CancelKeyPress += OnCancelKeyPress; + int exitCode; + bool cancellationRequested; try { if (isDotnetTestMode) - return await RunDotnetTestAsync (remaining); - - if (isInstrumentMode) - return await RunInstrumentationAsync (remaining); - - return await RunAppAsync (); + exitCode = await RunDotnetTestAsync (remaining); + else if (isInstrumentMode) + exitCode = await RunInstrumentationAsync (remaining); + else + exitCode = await RunAppAsync (); } finally { Console.CancelKeyPress -= OnCancelKeyPress; + cancellationRequested = Volatile.Read (ref ctrlCRequested) != 0; + if (cancellationRequested) + await StopAppAsync (); cts.Dispose (); } + + return cancellationRequested ? CtrlCExitCode : exitCode; } void OnCancelKeyPress (object? sender, ConsoleCancelEventArgs e) @@ -205,20 +214,8 @@ void OnCancelKeyPress (object? sender, ConsoleCancelEventArgs e) Console.WriteLine (); Console.WriteLine ("Stopping application..."); + Interlocked.Exchange (ref ctrlCRequested, 1); cts.Cancel (); - - // Force-stop the app (fire-and-forget in cancel handler) - _ = StopAppAsync (); - - // Kill logcat process if running - try { - if (logcatProcess != null && !logcatProcess.HasExited) { - logcatProcess.Kill (); - } - } catch (Exception ex) { - if (verbose) - Console.Error.WriteLine ($"Error killing logcat process: {ex.Message}"); - } } async Task RunInstrumentationAsync (List instrumentationArgs) @@ -616,7 +613,18 @@ async Task StopAppAsync () return; var userArg = string.IsNullOrEmpty (deviceUserId) ? "" : $" --user {deviceUserId}"; - await AdbHelper.RunAsync (adbPath, adbTarget, $"shell am force-stop{userArg} {package}", CancellationToken.None, verbose); + using var timeoutCts = new CancellationTokenSource (TimeSpan.FromSeconds (StopAppTimeoutSeconds)); + try { + var (exitCode, _, error) = await AdbHelper.RunAsync (adbPath, adbTarget, $"shell am force-stop{userArg} {package}", timeoutCts.Token, verbose); + if (exitCode != 0) + Console.Error.WriteLine ($"Error: Failed to stop app: {error}"); + } catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested) { + Console.Error.WriteLine ($"Error: Timed out stopping app after {StopAppTimeoutSeconds} seconds."); + } catch (Exception ex) { + Console.Error.WriteLine ($"Error: Failed to stop app: {ex.Message}"); + if (verbose) + Console.Error.WriteLine (ex.ToString ()); + } } string? FindAdbPath () diff --git a/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs b/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs index 8a8c5355db1..33c8ffc78a2 100644 --- a/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs +++ b/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs @@ -474,24 +474,18 @@ public void DotNetRunCtrlC () // Wait for the process to exit gracefully bool exited = process.WaitForExit (30_000); Assert.IsTrue (exited, "dotnet run process should have exited after SIGINT"); + Assert.AreEqual (130, process.ExitCode, "dotnet run process should report user cancellation 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"); + Assert.IsFalse (outputText.Contains ("Error: The operation was canceled."), + "Cancellation should not be reported as an error"); - // 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, + // Microsoft.Android.Run must not exit until force-stop has completed. + 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