Skip to content

Commit e391f57

Browse files
SuperJMNCopilot
andcommitted
fix(android): retry once on transient XARDF7024 RemoveDirFixed race
Xamarin.Android's RemoveDirFixed task occasionally fails with 'XARDF7024: Directory not empty' on obj/.../android/... during dotnet publish. The race is amplified on NTFS volumes mounted via the Linux ntfs3 driver but is not specific to it. When AndroidPublishExecutor.Publish detects this signature in the publish output, it now removes obj/Release/<tfm>/android and retries dotnet publish once. Unrelated failures still fail fast. Tracking upstream in dotnet/android#10124. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent df6ea8e commit e391f57

2 files changed

Lines changed: 194 additions & 6 deletions

File tree

src/DotnetDeployer/Packaging/Android/AndroidPublishExecutor.cs

Lines changed: 86 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -56,13 +56,93 @@ public async Task<Result> Publish(string projectPath, string publishArgs, string
5656
return Result.Failure(UnsupportedHostMessage());
5757
}
5858

59-
var native = await command.Execute(
60-
"dotnet",
61-
$"publish \"{projectPath}\" {publishArgs}",
62-
workingDirectory);
63-
return native.IsSuccess
59+
var arguments = $"publish \"{projectPath}\" {publishArgs}";
60+
61+
var native = await command.Execute("dotnet", arguments, workingDirectory);
62+
if (native.IsSuccess)
63+
{
64+
return Result.Success();
65+
}
66+
67+
if (!IsTransientObjDirectoryRace(native.Error))
68+
{
69+
return Result.Failure(native.Error);
70+
}
71+
72+
logger.Warning(
73+
"Detected transient Xamarin.Android XARDF7024 race on obj/. Cleaning the Android obj subtree and retrying once. " +
74+
"See https://github.com/dotnet/android/issues/10124.");
75+
76+
TryCleanAndroidObj(workingDirectory, publishArgs);
77+
78+
var retry = await command.Execute("dotnet", arguments, workingDirectory);
79+
return retry.IsSuccess
6480
? Result.Success()
65-
: Result.Failure(native.Error);
81+
: Result.Failure(retry.Error);
82+
}
83+
84+
public static bool IsTransientObjDirectoryRace(string? error)
85+
{
86+
if (string.IsNullOrEmpty(error))
87+
{
88+
return false;
89+
}
90+
91+
// Xamarin.Android RemoveDirFixed race: error XARDF7024 with "Directory not empty"
92+
// pointing at an obj/.../android/... path. Both signals must be present to avoid
93+
// false positives on unrelated IO failures.
94+
return error.Contains("XARDF7024", StringComparison.Ordinal)
95+
|| (error.Contains("Directory not empty", StringComparison.OrdinalIgnoreCase)
96+
&& error.Contains("RemoveDirFixed", StringComparison.Ordinal));
97+
}
98+
99+
private void TryCleanAndroidObj(string workingDirectory, string publishArgs)
100+
{
101+
try
102+
{
103+
var tfm = ExtractTargetFramework(publishArgs);
104+
var objRoot = Path.Combine(workingDirectory, "obj", "Release");
105+
106+
if (tfm is not null)
107+
{
108+
var tfmDir = Path.Combine(objRoot, tfm, "android");
109+
if (Directory.Exists(tfmDir))
110+
{
111+
logger.Debug("Removing {Path} before retry", tfmDir);
112+
Directory.Delete(tfmDir, recursive: true);
113+
return;
114+
}
115+
}
116+
117+
if (Directory.Exists(objRoot))
118+
{
119+
logger.Debug("Removing {Path} before retry (TFM not parseable)", objRoot);
120+
Directory.Delete(objRoot, recursive: true);
121+
}
122+
}
123+
catch (Exception ex)
124+
{
125+
logger.Warning(ex, "Failed to clean Android obj subtree before retry; will retry anyway");
126+
}
127+
}
128+
129+
public static string? ExtractTargetFramework(string publishArgs)
130+
{
131+
if (string.IsNullOrWhiteSpace(publishArgs))
132+
{
133+
return null;
134+
}
135+
136+
var tokens = publishArgs.Split(' ', StringSplitOptions.RemoveEmptyEntries);
137+
for (var i = 0; i < tokens.Length - 1; i++)
138+
{
139+
if (tokens[i] is "-f" or "--framework")
140+
{
141+
return tokens[i + 1].Trim('"');
142+
}
143+
}
144+
145+
return null;
66146
}
67147

68148
internal static string UnsupportedHostMessage() =>

test/DotnetDeployer.Tests/Platforms/Android/AndroidPublishExecutorTests.cs

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
using System.Runtime.InteropServices;
2+
using CSharpFunctionalExtensions;
23
using DotnetDeployer.Packaging.Android;
4+
using Serilog;
5+
using Zafiro.Commands;
6+
using ICommand = Zafiro.Commands.ICommand;
37

48
namespace DotnetDeployer.Tests.Platforms.Android;
59

@@ -25,4 +29,108 @@ public void UnsupportedHostMessage_points_users_to_the_tracking_repo_and_upstrea
2529
Assert.Contains("DotnetAndroidArm64Shims", msg);
2630
Assert.Contains("dotnet/android/issues/11184", msg);
2731
}
32+
33+
[Theory]
34+
[InlineData("error XARDF7024: System.IO.IOException: Directory not empty", true)]
35+
[InlineData("Some other failure", false)]
36+
[InlineData("Directory not empty\n at Xamarin.Android.Tasks.RemoveDirFixed.RunTask()", true)]
37+
[InlineData("Directory not empty without the marker", false)]
38+
[InlineData("", false)]
39+
[InlineData(null, false)]
40+
public void IsTransientObjDirectoryRace_detects_the_xamarin_android_race(string? error, bool expected)
41+
{
42+
Assert.Equal(expected, AndroidPublishExecutor.IsTransientObjDirectoryRace(error));
43+
}
44+
45+
[Theory]
46+
[InlineData("-c Release -f net10.0-android -p:Version=1.2.3", "net10.0-android")]
47+
[InlineData("-c Release --framework net9.0-android", "net9.0-android")]
48+
[InlineData("-c Release", null)]
49+
[InlineData("", null)]
50+
public void ExtractTargetFramework_parses_the_f_token(string args, string? expected)
51+
{
52+
Assert.Equal(expected, AndroidPublishExecutor.ExtractTargetFramework(args));
53+
}
54+
55+
[Fact]
56+
public async Task Publish_retries_once_on_xardf7024_and_succeeds()
57+
{
58+
if (AndroidPublishExecutor.IsHostUnsupported)
59+
{
60+
return;
61+
}
62+
63+
var workingDir = Path.Combine(Path.GetTempPath(), $"deployer-test-{Guid.NewGuid():N}");
64+
var staleObj = Path.Combine(workingDir, "obj", "Release", "net10.0-android", "android", "assets", "arm64-v8a");
65+
Directory.CreateDirectory(staleObj);
66+
File.WriteAllText(Path.Combine(staleObj, "stale.bin"), "x");
67+
68+
try
69+
{
70+
var fake = new ScriptedCommand([
71+
Result.Failure<string>("error XARDF7024: System.IO.IOException: Directory not empty\n at Xamarin.Android.Tasks.RemoveDirFixed.RunTask()"),
72+
Result.Success(string.Empty)
73+
]);
74+
75+
var executor = new AndroidPublishExecutor(fake, new LoggerConfiguration().CreateLogger());
76+
77+
var result = await executor.Publish(
78+
"/some/project.csproj",
79+
"-c Release -f net10.0-android",
80+
workingDir);
81+
82+
Assert.True(result.IsSuccess, result.IsFailure ? result.Error : "");
83+
Assert.Equal(2, fake.Calls);
84+
Assert.False(Directory.Exists(Path.Combine(workingDir, "obj", "Release", "net10.0-android", "android")),
85+
"Stale android obj subtree should have been removed before retry.");
86+
}
87+
finally
88+
{
89+
if (Directory.Exists(workingDir))
90+
{
91+
Directory.Delete(workingDir, recursive: true);
92+
}
93+
}
94+
}
95+
96+
[Fact]
97+
public async Task Publish_does_not_retry_on_unrelated_failures()
98+
{
99+
if (AndroidPublishExecutor.IsHostUnsupported)
100+
{
101+
return;
102+
}
103+
104+
var fake = new ScriptedCommand([
105+
Result.Failure<string>("error CS0103: The name 'Foo' does not exist")
106+
]);
107+
108+
var executor = new AndroidPublishExecutor(fake, new LoggerConfiguration().CreateLogger());
109+
110+
var result = await executor.Publish(
111+
"/some/project.csproj",
112+
"-c Release -f net10.0-android",
113+
Path.GetTempPath());
114+
115+
Assert.True(result.IsFailure);
116+
Assert.Equal(1, fake.Calls);
117+
}
118+
119+
private sealed class ScriptedCommand : ICommand
120+
{
121+
private readonly Queue<Result<string>> responses;
122+
123+
public ScriptedCommand(IEnumerable<Result<string>> responses)
124+
{
125+
this.responses = new Queue<Result<string>>(responses);
126+
}
127+
128+
public int Calls { get; private set; }
129+
130+
public Task<Result<string>> Execute(string command, string arguments, string workingDirectory = "", Dictionary<string, string>? environmentVariables = null)
131+
{
132+
Calls++;
133+
return Task.FromResult(responses.Dequeue());
134+
}
135+
}
28136
}

0 commit comments

Comments
 (0)