Skip to content

Commit 60fae19

Browse files
authored
[Microsoft.Android.Build.BaseTasks] retry when copying files (#245)
Context: dotnet/android#9133 Context: https://learn.microsoft.com/visualstudio/msbuild/copy-task?view=vs-2022 We sometimes get collisions between the Design-Time-Build (or AntiVirus) and our main build. This can result in errors such as: Error (active) XALNS7019 System.UnauthorizedAccessException: Access to the path 'D:\Projects\MauiApp2\obj\Debug\net9.0-android\android\assets\armeabi-v7a\MauiApp2.dll' is denied. at System.IO.__Error.WinIOError(Int32 errorCode, String maybeFullPath) at System.IO.File.InternalDelete(String path, Boolean checkHost) at Microsoft.Android.Build.Tasks.Files.CopyIfChanged(String source, String destination) in /Users/runner/work/1/s/xamarin-android/external/xamarin-android-tools/src/Microsoft.Android.Build.BaseTasks/Files.cs:line 125 at Xamarin.Android.Tasks.MonoAndroidHelper.CopyAssemblyAndSymbols(String source, String destination) in /Users/runner/work/1/s/xamarin-android/src/Xamarin.Android.Build.Tasks/Utilities/MonoAndroidHelper.cs:line 344 at Xamarin.Android.Tasks.LinkAssembliesNoShrink.CopyIfChanged(ITaskItem source, ITaskItem destination) in /Users/runner/work/1/s/xamarin-android/src/Xamarin.Android.Build.Tasks/Tasks/LinkAssembliesNoShrink.cs:line 161 at Xamarin.Android.Tasks.LinkAssembliesNoShrink.RunTask() in /Users/runner/work/1/s/xamarin-android/src/Xamarin.Android.Build.Tasks/Tasks/LinkAssembliesNoShrink.cs:line 76 at Microsoft.Android.Build.Tasks.AndroidTask.Execute() in /Users/runner/work/1/s/xamarin-android/external/xamarin-android-tools/src/Microsoft.Android.Build.BaseTasks/AndroidTask.cs:line 25 MauiApp2 (net9.0-android) C:\Program Files\dotnet\packs\Microsoft.Android.Sdk.Windows\34.99.0-preview.6.340\tools\Xamarin.Android.Common.targets 1407 If we look at the [MSBuild `<Copy/>` task][0] we see that it has a retry system in the cases of `UnauthorizedAccessException` or `IOException` when the code is `ACCESS_DENIED` or `ERROR_SHARING_VIOLATION`. The `<Copy/>` task also has public `Retries` and `RetryDelayMilliseconds` properties to control behavior. Duplicate that kind of logic into our `Files.Copy*IfChanged()` helper methods. This should give our builds a bit more resiliency to these kinds of issues. Instead of adding new `Files.Copy*IfChanged()` method overloads which accept "retries" and "retryDelay" parameters, we instead use environment variables to allow overriding these values: * `DOTNET_ANDROID_FILE_WRITE_RETRY_ATTEMPTS`: The number of times to try to retry a copy operation; corresponds to the `Copy.Retries` MSBuild task property. The default value is 10. * `DOTNET_ANDROID_FILE_WRITE_RETRY_DELAY_MS`: The amount of time, in milliseconds, to delay between attempted copies; corresponds to the `Copy.RetryDelayMilliseconds` MSBuild task property. The default value is 1000 ms. [0]: https://github.com/dotnet/msbuild/blob/main/src/Tasks/Copy.cs#L897
1 parent ab2165d commit 60fae19

File tree

3 files changed

+130
-0
lines changed

3 files changed

+130
-0
lines changed

src/Microsoft.Android.Build.BaseTasks/Files.cs

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,25 @@
99
using System.Text;
1010
using Xamarin.Tools.Zip;
1111
using Microsoft.Build.Utilities;
12+
using System.Threading;
13+
using System.Reflection.Metadata;
14+
using System.Runtime.InteropServices;
15+
using System.Collections;
1216

1317
namespace Microsoft.Android.Build.Tasks
1418
{
1519
public static class Files
1620
{
21+
const int ERROR_ACCESS_DENIED = -2147024891;
22+
const int ERROR_SHARING_VIOLATION = -2147024864;
23+
24+
const int DEFAULT_FILE_WRITE_RETRY_ATTEMPTS = 10;
25+
26+
const int DEFAULT_FILE_WRITE_RETRY_DELAY_MS = 1000;
27+
28+
static int fileWriteRetry = -1;
29+
static int fileWriteRetryDelay = -1;
30+
1731
/// <summary>
1832
/// Windows has a MAX_PATH limit of 260 characters
1933
/// See: https://docs.microsoft.com/en-us/windows/win32/fileio/naming-a-file#maximum-path-length-limitation
@@ -28,6 +42,37 @@ public static class Files
2842
public static readonly Encoding UTF8withoutBOM = new UTF8Encoding (encoderShouldEmitUTF8Identifier: false);
2943
readonly static byte[] Utf8Preamble = Encoding.UTF8.GetPreamble ();
3044

45+
/// <summary>
46+
/// Checks for the environment variable DOTNET_ANDROID_FILE_WRITE_RETRY_ATTEMPTS to
47+
/// see if a custom value for the number of times to retry writing a file has been
48+
/// set.
49+
/// </summary>
50+
/// <returns>The value of DOTNET_ANDROID_FILE_WRITE_RETRY_ATTEMPTS or the default of DEFAULT_FILE_WRITE_RETRY_ATTEMPTS</returns>
51+
public static int GetFileWriteRetryAttempts ()
52+
{
53+
if (fileWriteRetry == -1) {
54+
var retryVariable = Environment.GetEnvironmentVariable ("DOTNET_ANDROID_FILE_WRITE_RETRY_ATTEMPTS");
55+
if (string.IsNullOrEmpty (retryVariable) || !int.TryParse (retryVariable, out fileWriteRetry))
56+
fileWriteRetry = DEFAULT_FILE_WRITE_RETRY_ATTEMPTS;
57+
}
58+
return fileWriteRetry;
59+
}
60+
61+
/// <summary>
62+
/// Checks for the environment variable DOTNET_ANDROID_FILE_WRITE_RETRY_DELAY_MS to
63+
/// see if a custom value for the delay between trying to write a file has been
64+
/// set.
65+
/// </summary>
66+
/// <returns>The value of DOTNET_ANDROID_FILE_WRITE_RETRY_DELAY_MS or the default of DEFAULT_FILE_WRITE_RETRY_DELAY_MS</returns>
67+
public static int GetFileWriteRetryDelay ()
68+
{
69+
if (fileWriteRetryDelay == -1) {
70+
var delayVariable = Environment.GetEnvironmentVariable ("DOTNET_ANDROID_FILE_WRITE_RETRY_DELAY_MS");
71+
if (string.IsNullOrEmpty (delayVariable) || !int.TryParse (delayVariable, out fileWriteRetryDelay))
72+
fileWriteRetryDelay = DEFAULT_FILE_WRITE_RETRY_DELAY_MS;
73+
}
74+
return fileWriteRetryDelay;
75+
}
3176
/// <summary>
3277
/// Converts a full path to a \\?\ prefixed path that works on all Windows machines when over 260 characters
3378
/// NOTE: requires a *full path*, use sparingly
@@ -111,6 +156,33 @@ public static bool ArchiveZip (string target, Action<string> archiver)
111156
}
112157

113158
public static bool CopyIfChanged (string source, string destination)
159+
{
160+
int retryCount = 0;
161+
int attempts = GetFileWriteRetryAttempts ();
162+
int delay = GetFileWriteRetryDelay ();
163+
while (retryCount <= attempts) {
164+
try {
165+
return CopyIfChangedOnce (source, destination);
166+
} catch (Exception e) {
167+
switch (e) {
168+
case UnauthorizedAccessException:
169+
case IOException:
170+
int code = Marshal.GetHRForException (e);
171+
if ((code != ERROR_ACCESS_DENIED && code != ERROR_SHARING_VIOLATION) || retryCount == attempts) {
172+
throw;
173+
};
174+
break;
175+
default:
176+
throw;
177+
}
178+
}
179+
retryCount++;
180+
Thread.Sleep (delay);
181+
}
182+
return false;
183+
}
184+
185+
public static bool CopyIfChangedOnce (string source, string destination)
114186
{
115187
if (HasFileChanged (source, destination)) {
116188
var directory = Path.GetDirectoryName (destination);
@@ -157,6 +229,33 @@ public static bool CopyIfBytesChanged (byte[] bytes, string destination)
157229
}
158230

159231
public static bool CopyIfStreamChanged (Stream stream, string destination)
232+
{
233+
int retryCount = 0;
234+
int attempts = GetFileWriteRetryAttempts ();
235+
int delay = GetFileWriteRetryDelay ();
236+
while (retryCount <= attempts) {
237+
try {
238+
return CopyIfStreamChangedOnce (stream, destination);
239+
} catch (Exception e) {
240+
switch (e) {
241+
case UnauthorizedAccessException:
242+
case IOException:
243+
int code = Marshal.GetHRForException (e);
244+
if ((code != ERROR_ACCESS_DENIED && code != ERROR_SHARING_VIOLATION) || retryCount >= attempts) {
245+
throw;
246+
};
247+
break;
248+
default:
249+
throw;
250+
}
251+
}
252+
retryCount++;
253+
Thread.Sleep (delay);
254+
}
255+
return false;
256+
}
257+
258+
public static bool CopyIfStreamChangedOnce (Stream stream, string destination)
160259
{
161260
if (HasStreamChanged (stream, destination)) {
162261
var directory = Path.GetDirectoryName (destination);

src/Microsoft.Android.Build.BaseTasks/Microsoft.Android.Build.BaseTasks.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
<SignAssembly>true</SignAssembly>
1010
<AssemblyOriginatorKeyFile>..\..\product.snk</AssemblyOriginatorKeyFile>
1111
<AssemblyName>$(VendorPrefix)Microsoft.Android.Build.BaseTasks$(VendorSuffix)</AssemblyName>
12+
<LangVersion>12.0</LangVersion>
1213
</PropertyGroup>
1314

1415
<ItemGroup>

tests/Microsoft.Android.Build.BaseTasks-Tests/FilesTests.cs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
using System.IO;
77
using System.Runtime.InteropServices;
88
using System.Text;
9+
using System.Threading;
10+
using System.Threading.Tasks;
911
using Xamarin.Tools.Zip;
1012
using Microsoft.Android.Build.Tasks;
1113

@@ -436,6 +438,34 @@ public void CopyIfStreamChanged_CasingChange ()
436438
}
437439
}
438440

441+
[Test]
442+
public async Task CopyIfChanged_LockedFile ()
443+
{
444+
var dest = NewFile (contents: "foo", fileName: "foo_locked");
445+
var src = NewFile (contents: "foo0", fileName: "foo");
446+
using (var file = File.OpenWrite (dest)) {
447+
Assert.Throws<IOException> (() => Files.CopyIfChanged (src, dest));
448+
}
449+
src = NewFile (contents: "foo1", fileName: "foo");
450+
Assert.IsTrue (Files.CopyIfChanged (src, dest));
451+
src = NewFile (contents: "foo2", fileName: "foo");
452+
dest = NewFile (contents: "foo", fileName: "foo_locked2");
453+
var ev = new ManualResetEvent (false);
454+
var task = Task.Run (async () => {
455+
var file = File.Open (dest, FileMode.OpenOrCreate, FileAccess.Write, FileShare.Read);
456+
try {
457+
ev.Set ();
458+
await Task.Delay (2500);
459+
} finally {
460+
file.Close();
461+
file.Dispose ();
462+
}
463+
});
464+
ev.WaitOne ();
465+
Assert.IsTrue (Files.CopyIfChanged (src, dest));
466+
await task;
467+
}
468+
439469
[Test]
440470
public void ExtractAll ()
441471
{

0 commit comments

Comments
 (0)