Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
49 changes: 48 additions & 1 deletion src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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", ".");
}
Expand Down Expand Up @@ -395,6 +395,53 @@ async Task CheckAppInstalledAndDebuggable (string packageName)
return;
}

/// <summary>
/// Issues the first <c>run-as &lt;pkg&gt; pwd</c> query, retrying briefly while the
/// per-user data directory is not yet stat-able through <c>run-as</c>.
/// </summary>
/// <remarks>
/// <para>Immediately after <c>pm install</c>, the per-user data directory
/// <c>/data/user/N/&lt;pkg&gt;</c> may not yet be stat-able through <c>run-as</c>,
/// even for the primary user (id 0). During that window <c>run-as</c> returns
/// <c>run-as: couldn't stat /data/user/N/&lt;pkg&gt;: No such file or directory</c>,
/// which otherwise raises <c>XA0137</c> 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.</para>
/// <para>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 <c>XA0137</c>.
/// Only the transient <c>couldn't stat … No such file or directory</c> signature
/// (detected by <see cref="IsTransientRunAsStatRace"/>) triggers a retry; all other
/// <c>run-as</c> failures are surfaced immediately.</para>
/// </remarks>
async Task<string> QueryInternalPathWithRetry ()
{
const int maxAttempts = 10;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 💡 Documentation — This retry budget (10 attempts × 500 ms ≈ 4.5 s worst case; 9 sleeps) is an unexplained policy choice, whereas the sibling EnsureUserIsRunning documents N=50 measurements and explicitly notes that for secondary users "polling alone is not sufficient (30% never recover even after 30 s)". Since this path relies on polling alone, a one-line note on why ~4.5 s is expected to cover the user-0 /data/user/0/<pkg> materialization window — the key difference being that user 0 is already running, so the directory is guaranteed to appear and is only briefly delayed — would pre-empt the obvious "why not 30 s like the other case?" question.

(Rule: Document non-obvious behavioral decisions, Postmortem #13)

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;
}

/// <summary>
/// Returns <see langword="true"/> when a <c>run-as</c> result matches the transient
/// install-vs-run-as race signature (<c>couldn't stat … No such file or directory</c>),
/// i.e. the per-user data directory has not yet materialized after <c>pm install</c>.
/// </summary>
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;
}

/// <summary>
/// Ensures the secondary Android user targeted by this deployment is in the
/// 'running' state before any <c>run-as</c> query is issued against it.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,4 +74,54 @@ public void TestResolveToolsExists ()
}

}

/// <summary>
/// Unit tests for <see cref="FastDeploy"/> helper methods that do not require a device.
/// </summary>
[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}");
}
}
}
Loading