Skip to content

Commit 814f5a0

Browse files
[FastDeploy] Retry first run-as query to absorb post-install data-dir race (XA0137) (#11809)
The Fast Deployment install-vs-`run-as` race (#7821) fires `XA0137` (`run-as: couldn't stat /data/user/0/<pkg>: No such file or directory`) on the **primary user (user 0)** ~daily in CI. The existing `EnsureUserIsRunning` mitigation (#11322) only covers secondary users and explicitly skips user 0 — and `am start-user -w 0` wouldn't help anyway, since user 0 is already running; the real issue is the brief window after `pm install` before `/data/user/0/<pkg>` is stat-able through `run-as`. ## Changes `src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy.cs`: - **`QueryInternalPathWithRetry`** — wraps the first `run-as <pkg> pwd` query in `CheckAppInstalledAndDebuggable` with a bounded poll (10 attempts × 500 ms) that retries while the result matches the transient race signature, before falling through to `RaiseRunAsError`/XA0137. Applies to **all** users, including user 0; no-op when the directory already exists. - **`IsTransientRunAsStatRace`** — matches the `couldn't stat … No such file or directory` signature only, so genuine `run-as` failures (permission denied, not debuggable, corrupt install, etc.) still surface immediately. ```csharp async Task<string> 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; } ``` `EnsureUserIsRunning` is left intact; the retry sits after it, so secondary users retain both mitigations. ## Notes No unit-test project exists for `Xamarin.Android.Build.Debugging.Tasks`; the path is exercised by the on-device `MSBuildDeviceIntegration` install tests where the race was observed. Co-authored-by: simonrozsival <374616+simonrozsival@users.noreply.github.com>
1 parent 36df374 commit 814f5a0

3 files changed

Lines changed: 100 additions & 1 deletion

File tree

src/Xamarin.Android.Build.Debugging.Tasks/Properties/AssemblyInfo.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,5 @@
1717

1818
// The following GUID is for the ID of the typelib if this project is exposed to COM
1919
[assembly: Guid ("42bb9aea-f4d8-4b43-8956-8e1fee857697")]
20+
21+
[assembly: InternalsVisibleTo ("Xamarin.Android.Build.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000011000000438ac2a5acfbf16cbd2b2b47a62762f273df9cb2795ceccdf77d10bf508e69e7a362ea7a45455bbf3ac955e1f2e2814f144e5d817efc4c6502cc012df310783348304e3ae38573c6d658c234025821fda87a0be8a0d504df564e2c93b2b878925f42503e9d54dfef9f9586d9e6f38a305769587b1de01f6c0410328b2c9733db")]

src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy.cs

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -362,7 +362,7 @@ async Task CheckAppInstalledAndDebuggable (string packageName)
362362
packageInfo.UserId = UserID;
363363
packageInfo.PackageName = packageName;
364364
await EnsureUserIsRunning ();
365-
packageInfo.InternalPath = packageInfo.InternalPath ?? await Device.RunAs (packageInfo, "pwd");
365+
packageInfo.InternalPath = packageInfo.InternalPath ?? await QueryInternalPathWithRetry ();
366366
if (packageInfo.InternalPath.IndexOf ("Permission denied", StringComparison.OrdinalIgnoreCase) >= 0) {
367367
packageInfo.InternalPath = await Device.RunAs (packageInfo, "readlink", "-f", ".");
368368
}
@@ -395,6 +395,53 @@ async Task CheckAppInstalledAndDebuggable (string packageName)
395395
return;
396396
}
397397

398+
/// <summary>
399+
/// Issues the first <c>run-as &lt;pkg&gt; pwd</c> query, retrying briefly while the
400+
/// per-user data directory is not yet stat-able through <c>run-as</c>.
401+
/// </summary>
402+
/// <remarks>
403+
/// <para>Immediately after <c>pm install</c>, the per-user data directory
404+
/// <c>/data/user/N/&lt;pkg&gt;</c> may not yet be stat-able through <c>run-as</c>,
405+
/// even for the primary user (id 0). During that window <c>run-as</c> returns
406+
/// <c>run-as: couldn't stat /data/user/N/&lt;pkg&gt;: No such file or directory</c>,
407+
/// which otherwise raises <c>XA0137</c> and disables Fast Deployment. This races
408+
/// install on the primary user ~daily in CI. Poll for a bounded period to let the
409+
/// directory materialize before giving up. See
410+
/// https://github.com/dotnet/android/issues/7821 and
411+
/// https://github.com/dotnet/android/issues/11808.</para>
412+
/// <para>Retry policy: up to 10 attempts with a 500 ms delay between each, giving
413+
/// a maximum wait of 4.5 seconds before the error is surfaced as <c>XA0137</c>.
414+
/// Only the transient <c>couldn't stat … No such file or directory</c> signature
415+
/// (detected by <see cref="IsTransientRunAsStatRace"/>) triggers a retry; all other
416+
/// <c>run-as</c> failures are surfaced immediately.</para>
417+
/// </remarks>
418+
async Task<string> QueryInternalPathWithRetry ()
419+
{
420+
const int maxAttempts = 10;
421+
var delay = TimeSpan.FromMilliseconds (500);
422+
string result = await Device.RunAs (packageInfo, "pwd");
423+
for (int attempt = 1; attempt < maxAttempts && IsTransientRunAsStatRace (result); attempt++) {
424+
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 ()}");
425+
await Task.Delay (delay, CancellationToken);
426+
result = await Device.RunAs (packageInfo, "pwd");
427+
}
428+
return result;
429+
}
430+
431+
/// <summary>
432+
/// Returns <see langword="true"/> when a <c>run-as</c> result matches the transient
433+
/// install-vs-run-as race signature (<c>couldn't stat … No such file or directory</c>),
434+
/// i.e. the per-user data directory has not yet materialized after <c>pm install</c>.
435+
/// </summary>
436+
internal static bool IsTransientRunAsStatRace (string result)
437+
{
438+
if (string.IsNullOrEmpty (result)) {
439+
return false;
440+
}
441+
return result.IndexOf ("couldn't stat", StringComparison.OrdinalIgnoreCase) >= 0 &&
442+
result.IndexOf ("No such file or directory", StringComparison.OrdinalIgnoreCase) >= 0;
443+
}
444+
398445
/// <summary>
399446
/// Ensures the secondary Android user targeted by this deployment is in the
400447
/// 'running' state before any <c>run-as</c> query is issued against it.

src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/DebuggingTasksTests.cs

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,4 +74,54 @@ public void TestResolveToolsExists ()
7474
}
7575

7676
}
77+
78+
/// <summary>
79+
/// Unit tests for <see cref="FastDeploy"/> helper methods that do not require a device.
80+
/// </summary>
81+
[TestFixture]
82+
public class FastDeployTests
83+
{
84+
// Canonical transient race output produced by the Android run-as tool when
85+
// the per-user data directory has not yet materialized after pm install.
86+
static readonly string [] TransientRaceOutputs = {
87+
"run-as: couldn't stat /data/user/0/com.example.app: No such file or directory",
88+
"run-as: couldn't stat /data/user/10/com.example.app: No such file or directory",
89+
// Verify case-insensitivity of the detection.
90+
"run-as: Couldn't Stat /data/user/0/com.example.app: No Such File Or Directory",
91+
// Extra surrounding whitespace / newlines as they may appear in raw adb output.
92+
" run-as: couldn't stat /data/user/0/com.example.app: No such file or directory\n",
93+
};
94+
95+
// Genuine run-as failures that must NOT be swallowed by the retry loop.
96+
static readonly string [] NonTransientOutputs = {
97+
// Null / empty — first guard in the implementation.
98+
null,
99+
"",
100+
// Successful pwd output — the data directory already exists.
101+
"/data/user/0/com.example.app",
102+
// Package not debuggable.
103+
"run-as: package 'com.example.app' is not debuggable",
104+
// Package not installed.
105+
"run-as: package 'com.example.app' is unknown",
106+
// Permission denied (SELinux / policy).
107+
"run-as: couldn't stat /data/user/0/com.example.app: Permission denied",
108+
// Only one of the two required substrings — must not match.
109+
"run-as: couldn't stat /data/user/0/com.example.app",
110+
"No such file or directory",
111+
};
112+
113+
[TestCaseSource (nameof (TransientRaceOutputs))]
114+
public void IsTransientRunAsStatRace_ReturnsTrueForRaceSignature (string output)
115+
{
116+
Assert.IsTrue (FastDeploy.IsTransientRunAsStatRace (output),
117+
$"Expected transient-race detection for: {output}");
118+
}
119+
120+
[TestCaseSource (nameof (NonTransientOutputs))]
121+
public void IsTransientRunAsStatRace_ReturnsFalseForNonTransientOutput (string output)
122+
{
123+
Assert.IsFalse (FastDeploy.IsTransientRunAsStatRace (output),
124+
$"Expected no transient-race detection for: {output}");
125+
}
126+
}
77127
}

0 commit comments

Comments
 (0)