From 69791761a20942c6318ffd2eb0ce56716e872f01 Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Fri, 7 Aug 2026 14:38:17 -0500 Subject: [PATCH 1/4] [Microsoft.Android.Run] await cancellation cleanup Ensure Ctrl+C waits for adb force-stop before the run tool exits, while preserving the standard cancellation exit code. Strengthen the device test to require shutdown completion without polling. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Microsoft.Android.Run/Program.cs | 41 ++++++++++--------- .../Tests/InstallAndRunTests.cs | 17 +++----- 2 files changed, 26 insertions(+), 32 deletions(-) diff --git a/src/Microsoft.Android.Run/Program.cs b/src/Microsoft.Android.Run/Program.cs index e0366f92066..42a68262beb 100644 --- a/src/Microsoft.Android.Run/Program.cs +++ b/src/Microsoft.Android.Run/Program.cs @@ -185,18 +185,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 = cts.IsCancellationRequested; + if (cancellationRequested) + await StopAppAsync (); cts.Dispose (); } + + return cancellationRequested ? 130 : exitCode; } void OnCancelKeyPress (object? sender, ConsoleCancelEventArgs e) @@ -206,19 +212,6 @@ void OnCancelKeyPress (object? sender, ConsoleCancelEventArgs e) Console.WriteLine ("Stopping application..."); 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 +609,15 @@ 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); + try { + var (exitCode, _, error) = await AdbHelper.RunAsync (adbPath, adbTarget, $"shell am force-stop{userArg} {package}", CancellationToken.None, verbose); + if (exitCode != 0) + Console.Error.WriteLine ($"Error: Failed to stop app: {error}"); + } 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..fcb3c87a548 100644 --- a/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs +++ b/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs @@ -479,19 +479,12 @@ public void DotNetRunCtrlC () 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 From e0d46e1e9fb5e9ae0a2a0d485e4bef780ca8c1cd Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Fri, 7 Aug 2026 14:41:08 -0500 Subject: [PATCH 2/4] [Microsoft.Android.Run] name Ctrl+C exit code Replace duplicated magic values with a documented constant. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Microsoft.Android.Run/Program.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Microsoft.Android.Run/Program.cs b/src/Microsoft.Android.Run/Program.cs index 42a68262beb..8184a65cf57 100644 --- a/src/Microsoft.Android.Run/Program.cs +++ b/src/Microsoft.Android.Run/Program.cs @@ -6,6 +6,7 @@ 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. string? adbPath = null; string? adbTarget = null; @@ -24,7 +25,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) @@ -202,7 +203,7 @@ async Task RunAsync (string[] args) cts.Dispose (); } - return cancellationRequested ? 130 : exitCode; + return cancellationRequested ? CtrlCExitCode : exitCode; } void OnCancelKeyPress (object? sender, ConsoleCancelEventArgs e) From 4b6ebf66a2a592a4fe19dd1fa464cf4191f1e6bd Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Fri, 7 Aug 2026 14:58:03 -0500 Subject: [PATCH 3/4] [Microsoft.Android.Run] distinguish user cancellation Track Ctrl+C independently from the cancellation token source because instrumentation also cancels that source during normal cleanup. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 80fed9de-25fd-4f10-9c83-145a24798a3c --- src/Microsoft.Android.Run/Program.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Microsoft.Android.Run/Program.cs b/src/Microsoft.Android.Run/Program.cs index 8184a65cf57..c1854ee36dd 100644 --- a/src/Microsoft.Android.Run/Program.cs +++ b/src/Microsoft.Android.Run/Program.cs @@ -18,6 +18,7 @@ int? logcatPid = null; Process? logcatProcess = null; CancellationTokenSource cts = new (); +int ctrlCRequested = 0; string? logcatArgs = null; bool isDotnetTestMode = false; string? dotnetTestPipe = null; @@ -197,7 +198,7 @@ async Task RunAsync (string[] args) exitCode = await RunAppAsync (); } finally { Console.CancelKeyPress -= OnCancelKeyPress; - cancellationRequested = cts.IsCancellationRequested; + cancellationRequested = Volatile.Read (ref ctrlCRequested) != 0; if (cancellationRequested) await StopAppAsync (); cts.Dispose (); @@ -212,6 +213,7 @@ void OnCancelKeyPress (object? sender, ConsoleCancelEventArgs e) Console.WriteLine (); Console.WriteLine ("Stopping application..."); + Interlocked.Exchange (ref ctrlCRequested, 1); cts.Cancel (); } From ed7aee6e6c391e3646b6f69061715d28663dbcb0 Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Mon, 10 Aug 2026 11:53:34 -0500 Subject: [PATCH 4/4] [Microsoft.Android.Run] bound Ctrl+C cleanup Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Microsoft.Android.Run/Program.cs | 6 +++++- tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Microsoft.Android.Run/Program.cs b/src/Microsoft.Android.Run/Program.cs index c1854ee36dd..a9064864b5f 100644 --- a/src/Microsoft.Android.Run/Program.cs +++ b/src/Microsoft.Android.Run/Program.cs @@ -7,6 +7,7 @@ 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; @@ -612,10 +613,13 @@ async Task StopAppAsync () return; var userArg = string.IsNullOrEmpty (deviceUserId) ? "" : $" --user {deviceUserId}"; + using var timeoutCts = new CancellationTokenSource (TimeSpan.FromSeconds (StopAppTimeoutSeconds)); try { - var (exitCode, _, error) = await AdbHelper.RunAsync (adbPath, adbTarget, $"shell am force-stop{userArg} {package}", CancellationToken.None, verbose); + 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) diff --git a/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs b/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs index fcb3c87a548..33c8ffc78a2 100644 --- a/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs +++ b/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs @@ -474,6 +474,7 @@ 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 ();