diff --git a/src/Xamarin.Android.Build.Debugging.Tasks/Properties/AssemblyInfo.cs b/src/Xamarin.Android.Build.Debugging.Tasks/Properties/AssemblyInfo.cs index ad636f063cf..c8a29cb2537 100644 --- a/src/Xamarin.Android.Build.Debugging.Tasks/Properties/AssemblyInfo.cs +++ b/src/Xamarin.Android.Build.Debugging.Tasks/Properties/AssemblyInfo.cs @@ -17,3 +17,5 @@ // The following GUID is for the ID of the typelib if this project is exposed to COM [assembly: Guid ("42bb9aea-f4d8-4b43-8956-8e1fee857697")] + +[assembly: InternalsVisibleTo ("Xamarin.Android.Build.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000011000000438ac2a5acfbf16cbd2b2b47a62762f273df9cb2795ceccdf77d10bf508e69e7a362ea7a45455bbf3ac955e1f2e2814f144e5d817efc4c6502cc012df310783348304e3ae38573c6d658c234025821fda87a0be8a0d504df564e2c93b2b878925f42503e9d54dfef9f9586d9e6f38a305769587b1de01f6c0410328b2c9733db")] diff --git a/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy.cs b/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy.cs index aa940b1d75d..13f72c72f78 100644 --- a/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy.cs +++ b/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy.cs @@ -362,7 +362,7 @@ async Task CheckAppInstalledAndDebuggable (string packageName) packageInfo.UserId = UserID; packageInfo.PackageName = packageName; await EnsureUserIsRunning (); - packageInfo.InternalPath = packageInfo.InternalPath ?? await Device.RunAs (packageInfo, "pwd"); + packageInfo.InternalPath = packageInfo.InternalPath ?? await QueryInternalPathWithRetry (); if (packageInfo.InternalPath.IndexOf ("Permission denied", StringComparison.OrdinalIgnoreCase) >= 0) { packageInfo.InternalPath = await Device.RunAs (packageInfo, "readlink", "-f", "."); } @@ -395,6 +395,53 @@ async Task CheckAppInstalledAndDebuggable (string packageName) return; } + /// + /// Issues the first run-as <pkg> pwd query, retrying briefly while the + /// per-user data directory is not yet stat-able through run-as. + /// + /// + /// Immediately after pm install, the per-user data directory + /// /data/user/N/<pkg> may not yet be stat-able through run-as, + /// even for the primary user (id 0). During that window run-as returns + /// run-as: couldn't stat /data/user/N/<pkg>: No such file or directory, + /// which otherwise raises XA0137 and disables Fast Deployment. This races + /// install on the primary user ~daily in CI. Poll for a bounded period to let the + /// directory materialize before giving up. See + /// https://github.com/dotnet/android/issues/7821 and + /// https://github.com/dotnet/android/issues/11808. + /// Retry policy: up to 10 attempts with a 500 ms delay between each, giving + /// a maximum wait of 4.5 seconds before the error is surfaced as XA0137. + /// Only the transient couldn't stat … No such file or directory signature + /// (detected by ) triggers a retry; all other + /// run-as failures are surfaced immediately. + /// + async Task QueryInternalPathWithRetry () + { + const int maxAttempts = 10; + var delay = TimeSpan.FromMilliseconds (500); + string result = await Device.RunAs (packageInfo, "pwd"); + for (int attempt = 1; attempt < maxAttempts && IsTransientRunAsStatRace (result); attempt++) { + LogDiagnostic ($"run-as could not stat the data directory for {packageInfo.PackageName} yet (attempt {attempt}/{maxAttempts}); retrying in {delay.TotalMilliseconds:0} ms. Output: {result?.Trim ()}"); + await Task.Delay (delay, CancellationToken); + result = await Device.RunAs (packageInfo, "pwd"); + } + return result; + } + + /// + /// Returns when a run-as result matches the transient + /// install-vs-run-as race signature (couldn't stat … No such file or directory), + /// i.e. the per-user data directory has not yet materialized after pm install. + /// + internal static bool IsTransientRunAsStatRace (string result) + { + if (string.IsNullOrEmpty (result)) { + return false; + } + return result.IndexOf ("couldn't stat", StringComparison.OrdinalIgnoreCase) >= 0 && + result.IndexOf ("No such file or directory", StringComparison.OrdinalIgnoreCase) >= 0; + } + /// /// Ensures the secondary Android user targeted by this deployment is in the /// 'running' state before any run-as query is issued against it. diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/DebuggingTasksTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/DebuggingTasksTests.cs index d5d824dc491..e04b015d4ed 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/DebuggingTasksTests.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/DebuggingTasksTests.cs @@ -74,4 +74,54 @@ public void TestResolveToolsExists () } } + + /// + /// Unit tests for helper methods that do not require a device. + /// + [TestFixture] + public class FastDeployTests + { + // Canonical transient race output produced by the Android run-as tool when + // the per-user data directory has not yet materialized after pm install. + static readonly string [] TransientRaceOutputs = { + "run-as: couldn't stat /data/user/0/com.example.app: No such file or directory", + "run-as: couldn't stat /data/user/10/com.example.app: No such file or directory", + // Verify case-insensitivity of the detection. + "run-as: Couldn't Stat /data/user/0/com.example.app: No Such File Or Directory", + // Extra surrounding whitespace / newlines as they may appear in raw adb output. + " run-as: couldn't stat /data/user/0/com.example.app: No such file or directory\n", + }; + + // Genuine run-as failures that must NOT be swallowed by the retry loop. + static readonly string [] NonTransientOutputs = { + // Null / empty — first guard in the implementation. + null, + "", + // Successful pwd output — the data directory already exists. + "/data/user/0/com.example.app", + // Package not debuggable. + "run-as: package 'com.example.app' is not debuggable", + // Package not installed. + "run-as: package 'com.example.app' is unknown", + // Permission denied (SELinux / policy). + "run-as: couldn't stat /data/user/0/com.example.app: Permission denied", + // Only one of the two required substrings — must not match. + "run-as: couldn't stat /data/user/0/com.example.app", + "No such file or directory", + }; + + [TestCaseSource (nameof (TransientRaceOutputs))] + public void IsTransientRunAsStatRace_ReturnsTrueForRaceSignature (string output) + { + Assert.IsTrue (FastDeploy.IsTransientRunAsStatRace (output), + $"Expected transient-race detection for: {output}"); + } + + [TestCaseSource (nameof (NonTransientOutputs))] + public void IsTransientRunAsStatRace_ReturnsFalseForNonTransientOutput (string output) + { + Assert.IsFalse (FastDeploy.IsTransientRunAsStatRace (output), + $"Expected no transient-race detection for: {output}"); + } + } }