From f17f435abf4cb4a326b5a13d9544b9d6e3910f03 Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Fri, 7 Aug 2026 14:40:38 -0500 Subject: [PATCH 1/4] [debugging] Retry JDWP process lookup Retry transient PID discovery with bounded exponential backoff and propagate JDWP attachment failures so RunActivity cannot report success while the app remains suspended. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Debugging/DebuggingExtensions.cs | 50 +++++++++---------- .../Devices/AndroidDeviceExtensions.cs | 29 ++++++----- 2 files changed, 42 insertions(+), 37 deletions(-) diff --git a/src/Xamarin.AndroidTools/Debugging/DebuggingExtensions.cs b/src/Xamarin.AndroidTools/Debugging/DebuggingExtensions.cs index fea84e4156e..628038090da 100644 --- a/src/Xamarin.AndroidTools/Debugging/DebuggingExtensions.cs +++ b/src/Xamarin.AndroidTools/Debugging/DebuggingExtensions.cs @@ -20,7 +20,9 @@ namespace Xamarin.AndroidTools.Debugging /// public static class DebuggingExtensions { - const int WAIT_BEFORE_RETRY_GET_PID = 250; + const int GET_PID_MAX_ATTEMPTS = 7; + const int GET_PID_INITIAL_RETRY_DELAY_MS = 250; + const int GET_PID_MAX_RETRY_DELAY_MS = 1000; const int WAIT_FOR_DEBUGGER_TO_ATTACH_MS = 1400; /// @@ -58,14 +60,8 @@ public async static Task StartWithDebuggingAsync(this IAndroidDevice device, Exe } await androidDevice.ExecuteIntentCommandAsync(configuration.RunCommand, configuration.LogWiter, token).ConfigureAwait(false); - if (javaDebugging) { - try { - await androidDevice.ConnectJdwpAsync (configuration, token).ConfigureAwait(false); - } catch (Exception ex) { - if (configuration.LogWiter != null) - configuration.LogWiter ($"warning: Could not connect Jdwp. {ex}"); - } - } + if (javaDebugging) + await androidDevice.ConnectJdwpAsync (configuration, token).ConfigureAwait(false); } /// @@ -175,27 +171,31 @@ public static async Task ConnectJdwpAsync(this AndroidDevice androidDevice, Exec { if (config.RunCommand != null && config.RunCommand is AmStartCommand amStartCommand && amStartCommand.EnableDebugging) { - var packageName = (config.RunCommand as AmStartCommand).PackageName; - var pid = await androidDevice.GetProcessIDAsync(packageName, 5, WAIT_BEFORE_RETRY_GET_PID, token); + var packageName = amStartCommand.PackageName; + var pid = await androidDevice.GetProcessIDAsync ( + packageName, + GET_PID_MAX_ATTEMPTS, + GET_PID_INITIAL_RETRY_DELAY_MS, + GET_PID_MAX_RETRY_DELAY_MS, + token + ).ConfigureAwait (false); if (pid <= 0) - { - throw new Exception("Process Not Found."); - } - - var jdwpClient = new JdwpClient(config.Debugger.JdwpHostName, config.Debugger.JdwpPort); + throw new InvalidOperationException ($"Could not find process for package '{packageName}' after {GET_PID_MAX_ATTEMPTS} attempts."); - await AdbServer.Default.ForwardPort(androidDevice, "tcp", jdwpClient.Port, "jdwp", pid, token); - try { - await jdwpClient.ConnectAsync (token); + using (var jdwpClient = new JdwpClient (config.Debugger.JdwpHostName, config.Debugger.JdwpPort)) { + await AdbServer.Default.ForwardPort (androidDevice, "tcp", jdwpClient.Port, "jdwp", pid, token); + try { + await jdwpClient.ConnectAsync (token); - // Keep the Connection for 1300 milliseconds, otherwise the Android OS ignores the connection! - // https://github.com/aosp-mirror/platform_frameworks_base/blob/6b28a227400749f4f8ad1f56799370e7c2cab149/core/java/android/os/Debug.java#L101C50-L101C54 - await Task.Delay (WAIT_FOR_DEBUGGER_TO_ATTACH_MS); + // Keep the Connection for 1300 milliseconds, otherwise the Android OS ignores the connection! + // https://github.com/aosp-mirror/platform_frameworks_base/blob/6b28a227400749f4f8ad1f56799370e7c2cab149/core/java/android/os/Debug.java#L101C50-L101C54 + await Task.Delay (WAIT_FOR_DEBUGGER_TO_ATTACH_MS, token); - await jdwpClient.DisconnectAsync (); - } finally { - await AdbServer.Default.KillForward (androidDevice, "tcp", jdwpClient.Port, token); + await jdwpClient.DisconnectAsync (); + } finally { + await AdbServer.Default.KillForward (androidDevice, "tcp", jdwpClient.Port, CancellationToken.None); + } } } } diff --git a/src/Xamarin.AndroidTools/Devices/AndroidDeviceExtensions.cs b/src/Xamarin.AndroidTools/Devices/AndroidDeviceExtensions.cs index fd8c99298d1..72abb006c72 100644 --- a/src/Xamarin.AndroidTools/Devices/AndroidDeviceExtensions.cs +++ b/src/Xamarin.AndroidTools/Devices/AndroidDeviceExtensions.cs @@ -143,20 +143,25 @@ static Task KillProcessAndWaitForExitPreIcs (AndroidDevice device, string packag return tcs.Task; } - public static async Task GetProcessIDAsync(this AndroidDevice device, string packageName, int maxAttempts, int timeBetweenAttempts, CancellationToken token) + public static Task GetProcessIDAsync (this AndroidDevice device, string packageName, int maxAttempts, int timeBetweenAttempts, CancellationToken token) { - var retryCount = 1; - var pidOfResult = await device.GetProcessId(packageName, token); - AndroidLogger.LogDebug("GetProcessIDAsync", "PID of the application :: " + pidOfResult); - - while (pidOfResult <= 0 && retryCount <= maxAttempts) - { - await Task.Delay(timeBetweenAttempts); - pidOfResult = await device.GetProcessId(packageName, token); - AndroidLogger.LogDebug("GetProcessIDAsync", "Retrying " + retryCount + " time(s) to get PID of the application"); - retryCount++; + return GetProcessIDAsync (device, packageName, maxAttempts + 1, timeBetweenAttempts, timeBetweenAttempts, token); + } + + public static async Task GetProcessIDAsync (this AndroidDevice device, string packageName, int maxAttempts, int initialDelayMilliseconds, int maxDelayMilliseconds, CancellationToken token) + { + var delayMilliseconds = initialDelayMilliseconds; + for (var attempt = 1; attempt <= maxAttempts; attempt++) { + var pid = await device.GetProcessId (packageName, token); + AndroidLogger.LogDebug ("GetProcessIDAsync", $"Attempt {attempt} of {maxAttempts} to get PID of the application returned {pid}"); + if (pid > 0 || attempt == maxAttempts) + return pid; + + await Task.Delay (delayMilliseconds, token); + delayMilliseconds = Math.Min (delayMilliseconds * 2, maxDelayMilliseconds); } - return pidOfResult; + + return 0; } From f6f3ce8696929d51914e1cfa5efe31368af5c5a4 Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Fri, 7 Aug 2026 15:21:34 -0500 Subject: [PATCH 2/4] [debugging] Simplify JDWP process retries Use the existing fixed-delay PID lookup helper for a bounded five-second retry window instead of adding exponential-backoff plumbing. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f60a857e-d77c-48c4-ac14-f9bee00159ab --- .../Debugging/DebuggingExtensions.cs | 17 ++++------- .../Devices/AndroidDeviceExtensions.cs | 29 ++++++++----------- 2 files changed, 17 insertions(+), 29 deletions(-) diff --git a/src/Xamarin.AndroidTools/Debugging/DebuggingExtensions.cs b/src/Xamarin.AndroidTools/Debugging/DebuggingExtensions.cs index 628038090da..ca8a0ba2d62 100644 --- a/src/Xamarin.AndroidTools/Debugging/DebuggingExtensions.cs +++ b/src/Xamarin.AndroidTools/Debugging/DebuggingExtensions.cs @@ -20,9 +20,8 @@ namespace Xamarin.AndroidTools.Debugging /// public static class DebuggingExtensions { - const int GET_PID_MAX_ATTEMPTS = 7; - const int GET_PID_INITIAL_RETRY_DELAY_MS = 250; - const int GET_PID_MAX_RETRY_DELAY_MS = 1000; + const int GET_PID_RETRY_COUNT = 20; + const int WAIT_BEFORE_RETRY_GET_PID = 250; const int WAIT_FOR_DEBUGGER_TO_ATTACH_MS = 1400; /// @@ -169,19 +168,13 @@ public static bool IsWSA(this AndroidDevice androidDevice) public static async Task ConnectJdwpAsync(this AndroidDevice androidDevice, ExecutionConfiguration config, CancellationToken token) { - if (config.RunCommand != null && config.RunCommand is AmStartCommand amStartCommand && amStartCommand.EnableDebugging) + if (config.RunCommand is AmStartCommand amStartCommand && amStartCommand.EnableDebugging) { var packageName = amStartCommand.PackageName; - var pid = await androidDevice.GetProcessIDAsync ( - packageName, - GET_PID_MAX_ATTEMPTS, - GET_PID_INITIAL_RETRY_DELAY_MS, - GET_PID_MAX_RETRY_DELAY_MS, - token - ).ConfigureAwait (false); + var pid = await androidDevice.GetProcessIDAsync (packageName, GET_PID_RETRY_COUNT, WAIT_BEFORE_RETRY_GET_PID, token).ConfigureAwait (false); if (pid <= 0) - throw new InvalidOperationException ($"Could not find process for package '{packageName}' after {GET_PID_MAX_ATTEMPTS} attempts."); + throw new InvalidOperationException ($"Could not find process for package '{packageName}'."); using (var jdwpClient = new JdwpClient (config.Debugger.JdwpHostName, config.Debugger.JdwpPort)) { await AdbServer.Default.ForwardPort (androidDevice, "tcp", jdwpClient.Port, "jdwp", pid, token); diff --git a/src/Xamarin.AndroidTools/Devices/AndroidDeviceExtensions.cs b/src/Xamarin.AndroidTools/Devices/AndroidDeviceExtensions.cs index 72abb006c72..3be62b07d7d 100644 --- a/src/Xamarin.AndroidTools/Devices/AndroidDeviceExtensions.cs +++ b/src/Xamarin.AndroidTools/Devices/AndroidDeviceExtensions.cs @@ -143,25 +143,20 @@ static Task KillProcessAndWaitForExitPreIcs (AndroidDevice device, string packag return tcs.Task; } - public static Task GetProcessIDAsync (this AndroidDevice device, string packageName, int maxAttempts, int timeBetweenAttempts, CancellationToken token) + public static async Task GetProcessIDAsync(this AndroidDevice device, string packageName, int maxAttempts, int timeBetweenAttempts, CancellationToken token) { - return GetProcessIDAsync (device, packageName, maxAttempts + 1, timeBetweenAttempts, timeBetweenAttempts, token); - } - - public static async Task GetProcessIDAsync (this AndroidDevice device, string packageName, int maxAttempts, int initialDelayMilliseconds, int maxDelayMilliseconds, CancellationToken token) - { - var delayMilliseconds = initialDelayMilliseconds; - for (var attempt = 1; attempt <= maxAttempts; attempt++) { - var pid = await device.GetProcessId (packageName, token); - AndroidLogger.LogDebug ("GetProcessIDAsync", $"Attempt {attempt} of {maxAttempts} to get PID of the application returned {pid}"); - if (pid > 0 || attempt == maxAttempts) - return pid; - - await Task.Delay (delayMilliseconds, token); - delayMilliseconds = Math.Min (delayMilliseconds * 2, maxDelayMilliseconds); + var retryCount = 1; + var pidOfResult = await device.GetProcessId(packageName, token); + AndroidLogger.LogDebug("GetProcessIDAsync", "PID of the application :: " + pidOfResult); + + while (pidOfResult <= 0 && retryCount <= maxAttempts) + { + await Task.Delay(timeBetweenAttempts, token); + pidOfResult = await device.GetProcessId(packageName, token); + AndroidLogger.LogDebug("GetProcessIDAsync", "Retrying " + retryCount + " time(s) to get PID of the application"); + retryCount++; } - - return 0; + return pidOfResult; } From 00ee8216db934980aa99c5641008c4ebc6f362cf Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Fri, 7 Aug 2026 15:23:11 -0500 Subject: [PATCH 3/4] [debugging] Document JDWP retry window Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f60a857e-d77c-48c4-ac14-f9bee00159ab --- src/Xamarin.AndroidTools/Debugging/DebuggingExtensions.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Xamarin.AndroidTools/Debugging/DebuggingExtensions.cs b/src/Xamarin.AndroidTools/Debugging/DebuggingExtensions.cs index ca8a0ba2d62..3519d6e6430 100644 --- a/src/Xamarin.AndroidTools/Debugging/DebuggingExtensions.cs +++ b/src/Xamarin.AndroidTools/Debugging/DebuggingExtensions.cs @@ -20,6 +20,7 @@ namespace Xamarin.AndroidTools.Debugging /// public static class DebuggingExtensions { + // Twenty retries at 250 ms intervals allow up to five seconds for the process to appear. const int GET_PID_RETRY_COUNT = 20; const int WAIT_BEFORE_RETRY_GET_PID = 250; const int WAIT_FOR_DEBUGGER_TO_ATTACH_MS = 1400; From e7215115aa7970334df68acc4943ed6b00e2d56f Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Fri, 7 Aug 2026 15:31:52 -0500 Subject: [PATCH 4/4] [debugging] Test JDWP process retries Exercise eventual PID discovery, bounded exhaustion, and cancellation through an internal injectable process lookup. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f60a857e-d77c-48c4-ac14-f9bee00159ab --- src/Xamarin.AndroidTools/AssemblyInfo.cs | 1 + .../Devices/AndroidDeviceExtensions.cs | 9 ++- .../AndroidDeviceExtensionsTests.cs | 60 +++++++++++++++++++ ...arin.Android.Tools.AndroidSdk-Tests.csproj | 1 + 4 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 tests/Xamarin.Android.Tools.AndroidSdk-Tests/AndroidDeviceExtensionsTests.cs diff --git a/src/Xamarin.AndroidTools/AssemblyInfo.cs b/src/Xamarin.AndroidTools/AssemblyInfo.cs index 8f4867d5ff2..80bfd87ca30 100644 --- a/src/Xamarin.AndroidTools/AssemblyInfo.cs +++ b/src/Xamarin.AndroidTools/AssemblyInfo.cs @@ -30,3 +30,4 @@ "00240000048000009400000006020000002400005253413100040000010001008B63AA15D5222FA8E77F9DBFBE47AD82E74130B737743BC5" + "2A7074AD85D2EB34F28C83EFD0C1812C6957BAED24836300DF569EA0E6A71EEDA71C5E88A657AB8F243F268DA584B2D4EF4FCD3245B6A370" + "001A77DE21DE670E4678E29942F5628B153E88CD19504664FC663F9729DCA1CA2977875710EAF2CBD382DD6FD52BE9D3")] +[assembly: InternalsVisibleTo ("Xamarin.Android.Tools.AndroidSdk-Tests, PublicKey=0024000004800000940000000602000000240000525341310004000011000000438ac2a5acfbf16cbd2b2b47a62762f273df9cb2795ceccdf77d10bf508e69e7a362ea7a45455bbf3ac955e1f2e2814f144e5d817efc4c6502cc012df310783348304e3ae38573c6d658c234025821fda87a0be8a0d504df564e2c93b2b878925f42503e9d54dfef9f9586d9e6f38a305769587b1de01f6c0410328b2c9733db")] diff --git a/src/Xamarin.AndroidTools/Devices/AndroidDeviceExtensions.cs b/src/Xamarin.AndroidTools/Devices/AndroidDeviceExtensions.cs index 3be62b07d7d..3a8a0a1071b 100644 --- a/src/Xamarin.AndroidTools/Devices/AndroidDeviceExtensions.cs +++ b/src/Xamarin.AndroidTools/Devices/AndroidDeviceExtensions.cs @@ -144,15 +144,20 @@ static Task KillProcessAndWaitForExitPreIcs (AndroidDevice device, string packag } public static async Task GetProcessIDAsync(this AndroidDevice device, string packageName, int maxAttempts, int timeBetweenAttempts, CancellationToken token) + { + return await GetProcessIDAsync (cancellationToken => device.GetProcessId (packageName, cancellationToken), maxAttempts, timeBetweenAttempts, token); + } + + internal static async Task GetProcessIDAsync (Func> getProcessId, int maxAttempts, int timeBetweenAttempts, CancellationToken token) { var retryCount = 1; - var pidOfResult = await device.GetProcessId(packageName, token); + var pidOfResult = await getProcessId (token); AndroidLogger.LogDebug("GetProcessIDAsync", "PID of the application :: " + pidOfResult); while (pidOfResult <= 0 && retryCount <= maxAttempts) { await Task.Delay(timeBetweenAttempts, token); - pidOfResult = await device.GetProcessId(packageName, token); + pidOfResult = await getProcessId (token); AndroidLogger.LogDebug("GetProcessIDAsync", "Retrying " + retryCount + " time(s) to get PID of the application"); retryCount++; } diff --git a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/AndroidDeviceExtensionsTests.cs b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/AndroidDeviceExtensionsTests.cs new file mode 100644 index 00000000000..989aea79c85 --- /dev/null +++ b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/AndroidDeviceExtensionsTests.cs @@ -0,0 +1,60 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; + +namespace Xamarin.Android.Tools.Tests; + +[TestFixture] +public class AndroidDeviceExtensionsTests +{ + [Test] + public async Task GetProcessIDAsyncRetriesUntilProcessAppears () + { + var processIds = new Queue (new [] { 0, 0, 1234 }); + + var processId = await AndroidDeviceExtensions.GetProcessIDAsync ( + _ => Task.FromResult (processIds.Dequeue ()), + maxAttempts: 20, + timeBetweenAttempts: 0, + token: CancellationToken.None + ); + + Assert.AreEqual (1234, processId); + Assert.AreEqual (0, processIds.Count); + } + + [Test] + public async Task GetProcessIDAsyncStopsAfterMaximumAttempts () + { + var attempts = 0; + + var processId = await AndroidDeviceExtensions.GetProcessIDAsync ( + _ => { + attempts++; + return Task.FromResult (0); + }, + maxAttempts: 2, + timeBetweenAttempts: 0, + token: CancellationToken.None + ); + + Assert.AreEqual (0, processId); + Assert.AreEqual (3, attempts); + } + + [Test] + public void GetProcessIDAsyncHonorsCancellation () + { + using (var cancellationTokenSource = new CancellationTokenSource ()) { + cancellationTokenSource.Cancel (); + + Assert.ThrowsAsync (() => AndroidDeviceExtensions.GetProcessIDAsync ( + _ => Task.FromResult (0), + maxAttempts: 20, + timeBetweenAttempts: 1, + token: cancellationTokenSource.Token + )); + } + } +} diff --git a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/Xamarin.Android.Tools.AndroidSdk-Tests.csproj b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/Xamarin.Android.Tools.AndroidSdk-Tests.csproj index dc5364cb852..189fa0bbb78 100644 --- a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/Xamarin.Android.Tools.AndroidSdk-Tests.csproj +++ b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/Xamarin.Android.Tools.AndroidSdk-Tests.csproj @@ -26,6 +26,7 @@ +