From 28eb2f7d045259d0c130a5cc8339e3ad104fa966 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Tue, 8 Sep 2026 12:14:00 +0200 Subject: [PATCH 1/3] tests: stabilize Gradle Facebook device test Retry recognized transient dependency-resolution build failures while keeping deployment and runtime validation single-shot. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../AndroidGradleProjectTests.cs | 44 ++++++++ .../Common/TransientBuildFailure.cs | 105 ++++++++++++++++++ .../Tests/InstallAndRunTests.cs | 46 +++++++- 3 files changed, 192 insertions(+), 3 deletions(-) create mode 100644 src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Common/TransientBuildFailure.cs diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/AndroidGradleProjectTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/AndroidGradleProjectTests.cs index 571312d6f43..6893dd26611 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/AndroidGradleProjectTests.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/AndroidGradleProjectTests.cs @@ -630,5 +630,49 @@ public void BindLibraryWithMultipleGradleVersions (string agpVersion, string gra FileAssert.Exists (Path.Combine (Root, builder.ProjectDirectory, proj.OutputPath, $"{moduleName}-release.aar")); } + [TestCase ( + "Plugin [id: 'com.android.application', version: '9.1.1'] was not found\n" + + "Searched in dotnet-public-maven(https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-maven/maven/v1)", + "AGP plugin resolution from dotnet-public-maven" + )] + [TestCase ( + "Could not GET an AndroidX module from dotnet-public-maven\nConnection reset", + "connection reset" + )] + [TestCase ( + "Could not HEAD an AGP plugin in dotnet-public-maven\npkgs.dev.azure.com: nodename nor servname provided, or not known", + "DNS resolution failure" + )] + [TestCase ( + "error XA4236: Cannot download Maven artifact 'com.facebook.android:facebook-bolts'.\n" + + "facebook-bolts-18.3.0.aar: nodename nor servname provided, or not known (pkgs.dev.azure.com:443)", + "DNS resolution failure" + )] + [TestCase ( + "Failed to install the following SDK components: platforms;android-37.0\nOperation timed out", + "network timeout" + )] + public void DetectTransientDependencyResolutionFailure (string buildOutput, string expectedReason) + { + Assert.IsTrue (TransientBuildFailure.TryGetDependencyResolutionReason (buildOutput.Split ('\n'), out string reason)); + Assert.AreEqual (expectedReason, reason); + } + + [TestCase ("error CS1002: ; expected")] + [TestCase ("error XAGRDL1000: 'Invalid' not found in root project")] + [TestCase ( + "Plugin [id: 'com.android.application', version: '99.0.0'] was not found\n" + + "Searched in MavenCentral" + )] + [TestCase ( + "Could not GET an artifact from dotnet-public-maven\n" + + "Response status code does not indicate success: 404 (Not Found)" + )] + public void DoNotClassifyPermanentBuildFailureAsTransient (string buildOutput) + { + Assert.IsFalse (TransientBuildFailure.TryGetDependencyResolutionReason (buildOutput.Split ('\n'), out string reason)); + Assert.AreEqual ("", reason); + } + } } diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Common/TransientBuildFailure.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Common/TransientBuildFailure.cs new file mode 100644 index 00000000000..1f793510535 --- /dev/null +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Common/TransientBuildFailure.cs @@ -0,0 +1,105 @@ +using System; +using System.Collections.Generic; + +namespace Xamarin.ProjectTools +{ + public static class TransientBuildFailure + { + static readonly string [] permanentHttpFailures = [ + "status code 401", + "status code 403", + "status code 404", + "response code: 401", + "response code: 403", + "response code: 404", + "response status code does not indicate success: 401", + "response status code does not indicate success: 403", + "response status code does not indicate success: 404", + ]; + + public static bool TryGetDependencyResolutionReason (IEnumerable buildOutput, out string reason) + { + if (buildOutput == null) + throw new ArgumentNullException (nameof (buildOutput)); + + bool hasDependencyResolutionFailure = false; + bool hasAgpPluginResolutionFailure = false; + bool usesDotNetPublicMaven = false; + bool hasPermanentHttpFailure = false; + string? transientReason = null; + + foreach (string line in buildOutput) { + hasDependencyResolutionFailure |= + Contains (line, "Could not resolve") || + Contains (line, "Could not download") || + Contains (line, "Could not get resource") || + Contains (line, "Could not GET") || + Contains (line, "Could not HEAD") || + Contains (line, "Cannot download Maven artifact") || + Contains (line, "Failed to install the following SDK components") || + Contains (line, "services.gradle.org/distributions/"); + hasAgpPluginResolutionFailure |= + Contains (line, "Plugin [id: 'com.android.application'") || + Contains (line, "com.android.application.gradle.plugin"); + usesDotNetPublicMaven |= Contains (line, TestEnvironment.DotNetPublicMaven); + hasPermanentHttpFailure |= ContainsAny (line, permanentHttpFailures); + + if (transientReason == null) { + if (Contains (line, "Connection reset")) { + transientReason = "connection reset"; + } else if ( + Contains (line, "nodename nor servname provided, or not known") || + Contains (line, "Name or service not known") || + Contains (line, "No such host is known") || + Contains (line, "Temporary failure in name resolution") + ) { + transientReason = "DNS resolution failure"; + } else if ( + Contains (line, "Operation timed out") || + Contains (line, "The operation has timed out") || + Contains (line, "Read timed out") || + Contains (line, "Connect timed out") || + Contains (line, "Connection timed out") + ) { + transientReason = "network timeout"; + } else if (Contains (line, "An error occurred while sending the request")) { + transientReason = "request send failure"; + } + } + } + + if (hasPermanentHttpFailure) { + reason = ""; + return false; + } + + if (hasDependencyResolutionFailure && transientReason != null) { + reason = transientReason; + return true; + } + + if (hasAgpPluginResolutionFailure && usesDotNetPublicMaven) { + reason = "AGP plugin resolution from dotnet-public-maven"; + return true; + } + + reason = ""; + return false; + } + + static bool Contains (string value, string text) + { + return value.IndexOf (text, StringComparison.OrdinalIgnoreCase) >= 0; + } + + static bool ContainsAny (string value, string [] values) + { + foreach (string candidate in values) { + if (Contains (value, candidate)) + return true; + } + + return false; + } + } +} diff --git a/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs b/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs index ccfa33128df..475ea7ce19b 100644 --- a/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs +++ b/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs @@ -3027,9 +3027,12 @@ public static void logEvent(String eventName) {{ }, }; proj.SetRuntime (runtime); - proj.MainActivity = proj.DefaultMainActivity.Replace ("//${AFTER_ONCREATE}", @" + proj.SetDefaultTargetDevice (); + var successMarker = $"GradleFBProj-{runtime}-{(isRelease ? "Release" : "Debug")}-{Guid.NewGuid ():N}-Success"; + proj.MainActivity = proj.DefaultMainActivity.Replace ("//${AFTER_ONCREATE}", $@" Facebook.FacebookSdk.InitializeSDK(this, Java.Lang.Boolean.True); Facebook.FacebookSdk.LogEvent(""TestFacebook""); +Console.WriteLine(""{successMarker}""); "); proj.AndroidManifest =@" @@ -3040,8 +3043,45 @@ public static void logEvent(String eventName) {{ "; using var builder = CreateApkBuilder (); - Assert.IsTrue (builder.Build (proj)); - RunProjectAndAssert (proj, builder); + const int maxBuildAttempts = 3; + var buildLogs = new List (); + var lastTransientReason = ""; + bool buildSucceeded = false; + builder.ThrowOnBuildFailure = false; + for (int attempt = 1; attempt <= maxBuildAttempts; attempt++) { + var buildLog = attempt == 1 ? "build.log" : $"build-retry-{attempt}.log"; + var buildLogPath = Path.Combine (Root, builder.ProjectDirectory, buildLog); + buildLogs.Add (buildLogPath); + builder.BuildLogFile = buildLog; + buildSucceeded = builder.Build (proj); + if (buildSucceeded) + break; + if (!TransientBuildFailure.TryGetDependencyResolutionReason (builder.LastBuildOutput, out lastTransientReason)) + break; + if (attempt == maxBuildAttempts) + break; + + var retryDelay = TimeSpan.FromSeconds (attempt * 10); + TestContext.Out.WriteLine ( + $"GradleFBProj build attempt {attempt}/{maxBuildAttempts} failed due to {lastTransientReason}. " + + $"Retrying in {retryDelay.TotalSeconds} seconds. Log: '{buildLogPath}'." + ); + Thread.Sleep (retryDelay); + } + Assert.IsTrue (buildSucceeded, + $"Build should have succeeded. Last transient reason: '{lastTransientReason}'. Build logs: {string.Join (", ", buildLogs)}"); + + builder.ThrowOnBuildFailure = true; + ClearAdbLogcat (); + Assert.IsTrue ( + MonitorAdbLogcat ( + CreateLineChecker (successMarker), + Path.Combine (Root, builder.ProjectDirectory, "startup-logcat.log"), + ActivityStartTimeoutInSeconds, + onMonitoringStarted: () => RunProjectAndAssert (proj, builder) + ), + $"Application output did not contain '{successMarker}'." + ); } [Test] From 154b387e7aed8b47e0cf8b29c971c055a1a72312 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Tue, 8 Sep 2026 12:54:21 +0200 Subject: [PATCH 2/3] tests: scope Maven probe failure classification Evaluate XA4236 permanent HTTP failures per artifact block so rejected JAR probes do not mask transient AAR failures. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../AndroidGradleProjectTests.cs | 33 ++++++++++++++ .../Common/TransientBuildFailure.cs | 45 ++++++++++++++++++- 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/AndroidGradleProjectTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/AndroidGradleProjectTests.cs index 6893dd26611..d5f34fb57d4 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/AndroidGradleProjectTests.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/AndroidGradleProjectTests.cs @@ -648,6 +648,18 @@ public void BindLibraryWithMultipleGradleVersions (string agpVersion, string gra "facebook-bolts-18.3.0.aar: nodename nor servname provided, or not known (pkgs.dev.azure.com:443)", "DNS resolution failure" )] + [TestCase ( + "error XA4236: Cannot download Maven artifact 'com.facebook.android:facebook-bolts'.\n" + + "error XA4236: - facebook-bolts-18.3.0.jar: Response status code does not indicate success: 401 (Unauthorized)\n" + + "error XA4236: - facebook-bolts-18.3.0.aar: nodename nor servname provided, or not known (pkgs.dev.azure.com:443)", + "DNS resolution failure" + )] + [TestCase ( + "error XA4236: Cannot download Maven artifact 'com.facebook.android:facebook-bolts'.\n" + + "error XA4236: - facebook-bolts-18.3.0.jar: Response status code does not indicate success: 404 (Not Found)\n" + + "error XA4236: - facebook-bolts-18.3.0.aar: Connection reset", + "connection reset" + )] [TestCase ( "Failed to install the following SDK components: platforms;android-37.0\nOperation timed out", "network timeout" @@ -668,6 +680,27 @@ public void DetectTransientDependencyResolutionFailure (string buildOutput, stri "Could not GET an artifact from dotnet-public-maven\n" + "Response status code does not indicate success: 404 (Not Found)" )] + [TestCase ( + "error XA4236: Cannot download Maven artifact 'com.facebook.android:facebook-bolts'.\n" + + "error XA4236: - facebook-bolts-18.3.0.jar: Connection reset\n" + + "error XA4236: - facebook-bolts-18.3.0.aar: Response status code does not indicate success: 401 (Unauthorized)" + )] + [TestCase ( + "error XA4236: Cannot download Maven artifact 'com.facebook.android:facebook-bolts'.\n" + + "error XA4236: - facebook-bolts-18.3.0.jar: Response status code does not indicate success: 404 (Not Found)\n" + + "error XA4236: - facebook-bolts-18.3.0.aar: Response status code does not indicate success: 404 (Not Found)" + )] + [TestCase ( + "error XA4236: Cannot download Maven artifact 'com.example:jar-only'.\n" + + "error XA4236: - jar-only-1.0.jar: Response status code does not indicate success: 404 (Not Found)" + )] + [TestCase ( + "error XA4236: Cannot download Maven artifact 'com.example:jar-only'.\n" + + "error XA4236: - jar-only-1.0.jar: Response status code does not indicate success: 404 (Not Found)\n" + + "error XA4236: Cannot download Maven artifact 'com.facebook.android:facebook-bolts'.\n" + + "error XA4236: - facebook-bolts-18.3.0.jar: Response status code does not indicate success: 404 (Not Found)\n" + + "error XA4236: - facebook-bolts-18.3.0.aar: Connection reset" + )] public void DoNotClassifyPermanentBuildFailureAsTransient (string buildOutput) { Assert.IsFalse (TransientBuildFailure.TryGetDependencyResolutionReason (buildOutput.Split ('\n'), out string reason)); diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Common/TransientBuildFailure.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Common/TransientBuildFailure.cs index 1f793510535..85db773d252 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Common/TransientBuildFailure.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Common/TransientBuildFailure.cs @@ -26,9 +26,25 @@ public static bool TryGetDependencyResolutionReason (IEnumerable buildOu bool hasAgpPluginResolutionFailure = false; bool usesDotNetPublicMaven = false; bool hasPermanentHttpFailure = false; + bool inMavenArtifactFailure = false; + bool hasPermanentJarProbeFailure = false; + bool hasAarDiagnostic = false; + bool hasPermanentAarFailure = false; string? transientReason = null; foreach (string line in buildOutput) { + if (Contains (line, "Cannot download Maven artifact")) { + hasPermanentHttpFailure |= HasPermanentMavenArtifactFailure ( + hasPermanentJarProbeFailure, + hasAarDiagnostic, + hasPermanentAarFailure + ); + inMavenArtifactFailure = true; + hasPermanentJarProbeFailure = false; + hasAarDiagnostic = false; + hasPermanentAarFailure = false; + } + hasDependencyResolutionFailure |= Contains (line, "Could not resolve") || Contains (line, "Could not download") || @@ -42,7 +58,18 @@ public static bool TryGetDependencyResolutionReason (IEnumerable buildOu Contains (line, "Plugin [id: 'com.android.application'") || Contains (line, "com.android.application.gradle.plugin"); usesDotNetPublicMaven |= Contains (line, TestEnvironment.DotNetPublicMaven); - hasPermanentHttpFailure |= ContainsAny (line, permanentHttpFailures); + + bool hasPermanentHttpFailureInLine = ContainsAny (line, permanentHttpFailures); + // Maven restore probes JAR before AAR, so a rejected JAR probe is not permanent + // when an AAR diagnostic follows for the same artifact. + if (inMavenArtifactFailure && IsMavenArtifactDiagnostic (line, ".jar:")) { + hasPermanentJarProbeFailure |= hasPermanentHttpFailureInLine; + } else if (inMavenArtifactFailure && IsMavenArtifactDiagnostic (line, ".aar:")) { + hasAarDiagnostic = true; + hasPermanentAarFailure |= hasPermanentHttpFailureInLine; + } else { + hasPermanentHttpFailure |= hasPermanentHttpFailureInLine; + } if (transientReason == null) { if (Contains (line, "Connection reset")) { @@ -68,6 +95,12 @@ public static bool TryGetDependencyResolutionReason (IEnumerable buildOu } } + hasPermanentHttpFailure |= HasPermanentMavenArtifactFailure ( + hasPermanentJarProbeFailure, + hasAarDiagnostic, + hasPermanentAarFailure + ); + if (hasPermanentHttpFailure) { reason = ""; return false; @@ -87,6 +120,16 @@ public static bool TryGetDependencyResolutionReason (IEnumerable buildOu return false; } + static bool HasPermanentMavenArtifactFailure (bool hasPermanentJarProbeFailure, bool hasAarDiagnostic, bool hasPermanentAarFailure) + { + return hasPermanentAarFailure || (hasPermanentJarProbeFailure && !hasAarDiagnostic); + } + + static bool IsMavenArtifactDiagnostic (string line, string extension) + { + return Contains (line, "XA4236: -") && Contains (line, extension); + } + static bool Contains (string value, string text) { return value.IndexOf (text, StringComparison.OrdinalIgnoreCase) >= 0; From 0c411d819ef0a3a1576dbd335d37355a2d3bcf80 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Tue, 8 Sep 2026 22:00:59 +0200 Subject: [PATCH 3/3] [Java.Interop] make Maven cache writes atomic Write downloads to temporary files before publishing them so interrupted GradleFBProj retries cannot reuse truncated artifacts. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Repositories/CachedMavenRepository.cs | 59 ++++++-- .../CachedMavenRepositoryTests.cs | 132 +++++++++++++++++- 2 files changed, 177 insertions(+), 14 deletions(-) diff --git a/external/Java.Interop/src/Java.Interop.Tools.Maven/Repositories/CachedMavenRepository.cs b/external/Java.Interop/src/Java.Interop.Tools.Maven/Repositories/CachedMavenRepository.cs index 3d1f00bfb62..70c51b6e981 100644 --- a/external/Java.Interop/src/Java.Interop.Tools.Maven/Repositories/CachedMavenRepository.cs +++ b/external/Java.Interop/src/Java.Interop.Tools.Maven/Repositories/CachedMavenRepository.cs @@ -48,11 +48,21 @@ public bool TryGetFilePath (Artifact artifact, string filename, [NotNullWhen (tr } if (repository.TryGetFile (artifact, filename, out var repo_stream)) { - Directory.CreateDirectory (GetArtifactDirectory (artifact)); - - using (var sw = File.Create (file)) - using (repo_stream) - repo_stream.CopyTo (sw); + var directory = GetArtifactDirectory (artifact); + Directory.CreateDirectory (directory); + var temporary_file = Path.Combine (directory, Path.GetRandomFileName ()); + + try { + using (var sw = File.Create (temporary_file)) + using (repo_stream) + repo_stream.CopyTo (sw); + + PublishTemporaryFile (temporary_file, file); + } catch (Exception ex) { + DeleteTemporaryFileAfterFailure (temporary_file, ex); + throw; + } + File.Delete (temporary_file); path = file; return true; @@ -69,12 +79,21 @@ public bool TryGetFilePath (Artifact artifact, string filename, [NotNullWhen (tr return file; if (repository.TryGetFile (artifact, filename, out var repo_stream)) { - Directory.CreateDirectory (GetArtifactDirectory (artifact)); - - using (var sw = File.Create (file)) - using (repo_stream) - await repo_stream.CopyToAsync (sw, 81920, cancellationToken); - + var directory = GetArtifactDirectory (artifact); + Directory.CreateDirectory (directory); + var temporary_file = Path.Combine (directory, Path.GetRandomFileName ()); + + try { + using (var sw = File.Create (temporary_file)) + using (repo_stream) + await repo_stream.CopyToAsync (sw, 81920, cancellationToken); + + PublishTemporaryFile (temporary_file, file); + } catch (Exception ex) { + DeleteTemporaryFileAfterFailure (temporary_file, ex); + throw; + } + File.Delete (temporary_file); return file; } @@ -82,6 +101,24 @@ public bool TryGetFilePath (Artifact artifact, string filename, [NotNullWhen (tr return null; } + static void PublishTemporaryFile (string temporaryFile, string file) + { + try { + File.Move (temporaryFile, file); + } catch (IOException) when (File.Exists (file)) { + // Another process completed the same artifact download first. + } + } + + static void DeleteTemporaryFileAfterFailure (string temporaryFile, Exception failure) + { + try { + File.Delete (temporaryFile); + } catch (Exception cleanupException) when (cleanupException is IOException || cleanupException is UnauthorizedAccessException) { + failure.Data ["MavenCacheTemporaryFileCleanupException"] = cleanupException; + } + } + /// /// Returns the on-disk path where the given + /// would be cached under . Does not download or check for existence. diff --git a/external/Java.Interop/tests/Java.Interop.Tools.Maven-Tests/CachedMavenRepositoryTests.cs b/external/Java.Interop/tests/Java.Interop.Tools.Maven-Tests/CachedMavenRepositoryTests.cs index 277466fad2b..05178348fc4 100644 --- a/external/Java.Interop/tests/Java.Interop.Tools.Maven-Tests/CachedMavenRepositoryTests.cs +++ b/external/Java.Interop/tests/Java.Interop.Tools.Maven-Tests/CachedMavenRepositoryTests.cs @@ -56,6 +56,71 @@ public void TryGetFilePath_HappyPath_DownloadsAndReturnsExpectedPath () CollectionAssert.AreEqual (content, File.ReadAllBytes (path!)); } + [Test] + public void TryGetFilePath_FailedDownloadDoesNotPopulateCache () + { + var artifact = new Artifact ("com.example", "lib", "1.0.0"); + var content = new byte [] { 1, 2, 3 }; + var inner = new FlakyRepository ("central", content); + var cache = new CachedMavenRepository (cache_dir, inner); + var path = cache.GetArtifactFilePath (artifact, "lib-1.0.0.jar"); + var directory = Path.GetDirectoryName (path); + if (directory is null) + throw new InvalidOperationException ($"Could not determine the directory for '{path}'."); + + Assert.Throws (() => cache.TryGetFilePath (artifact, "lib-1.0.0.jar", out _)); + Assert.IsFalse (File.Exists (path), "A failed download must not populate the final cache path."); + CollectionAssert.IsEmpty (Directory.GetFiles (directory), "A failed download must clean up its temporary file."); + + Assert.IsTrue (cache.TryGetFilePath (artifact, "lib-1.0.0.jar", out var actual)); + Assert.AreEqual (path, actual); + CollectionAssert.AreEqual (content, File.ReadAllBytes (path)); + Assert.AreEqual (2, inner.CallCount); + } + + [Test] + public async Task GetFilePathAsync_FailedDownloadDoesNotPopulateCache () + { + var artifact = new Artifact ("com.example", "lib", "1.0.0"); + var content = new byte [] { 1, 2, 3 }; + var inner = new FlakyRepository ("central", content); + var cache = new CachedMavenRepository (cache_dir, inner); + var path = cache.GetArtifactFilePath (artifact, "lib-1.0.0.jar"); + var directory = Path.GetDirectoryName (path); + if (directory is null) + throw new InvalidOperationException ($"Could not determine the directory for '{path}'."); + + Assert.ThrowsAsync (async () => + await cache.GetFilePathAsync (artifact, "lib-1.0.0.jar", CancellationToken.None)); + Assert.IsFalse (File.Exists (path), "A failed download must not populate the final cache path."); + CollectionAssert.IsEmpty (Directory.GetFiles (directory), "A failed download must clean up its temporary file."); + + var actual = await cache.GetFilePathAsync (artifact, "lib-1.0.0.jar", CancellationToken.None); + + Assert.AreEqual (path, actual); + CollectionAssert.AreEqual (content, File.ReadAllBytes (path)); + Assert.AreEqual (2, inner.CallCount); + } + + [Test] + public async Task GetFilePathAsync_ConcurrentPublisherUsesCompletedFile () + { + var artifact = new Artifact ("com.example", "lib", "1.0.0"); + var content = new byte [] { 1, 2, 3 }; + var path = Path.GetFullPath (Path.Combine (cache_dir, "central", "com.example", "lib", "1.0.0", "lib-1.0.0.jar")); + var directory = Path.GetDirectoryName (path); + if (directory is null) + throw new InvalidOperationException ($"Could not determine the directory for '{path}'."); + var inner = new StubRepository ("central", artifact, "lib-1.0.0.jar", () => new PublishingStream (path, content)); + var cache = new CachedMavenRepository (cache_dir, inner); + + var actual = await cache.GetFilePathAsync (artifact, "lib-1.0.0.jar", CancellationToken.None); + + Assert.AreEqual (path, actual); + CollectionAssert.AreEqual (content, File.ReadAllBytes (path)); + CollectionAssert.AreEqual (new [] { path }, Directory.GetFiles (directory)); + } + [Test] public void GetArtifactFilePath_RelativeFilename_Throws () { @@ -142,14 +207,19 @@ sealed class StubRepository : IMavenRepository { readonly Artifact expected; readonly string expected_filename; - readonly byte [] content; + readonly Func stream_factory; public StubRepository (string name, Artifact expected, string filename, byte [] content) + : this (name, expected, filename, () => new MemoryStream (content)) + { + } + + public StubRepository (string name, Artifact expected, string filename, Func streamFactory) { Name = name; this.expected = expected; this.expected_filename = filename; - this.content = content; + stream_factory = streamFactory; } public string Name { get; } @@ -157,7 +227,7 @@ public StubRepository (string name, Artifact expected, string filename, byte [] public bool TryGetFile (Artifact artifact, string filename, [NotNullWhen (true)] out Stream? stream) { if (artifact.GroupId == expected.GroupId && artifact.Id == expected.Id && artifact.Version == expected.Version && filename == expected_filename) { - stream = new MemoryStream (content); + stream = stream_factory (); return true; } stream = null; @@ -182,4 +252,60 @@ public bool TryGetFile (Artifact artifact, string filename, [NotNullWhen (true)] throw new InvalidOperationException ("Inner repository should not be consulted when the resolved path escapes the cache directory."); } } + + sealed class FlakyRepository : IMavenRepository + { + readonly byte [] content; + + public FlakyRepository (string name, byte [] content) + { + Name = name; + this.content = content; + } + + public string Name { get; } + + public int CallCount { get; private set; } + + public bool TryGetFile (Artifact artifact, string filename, [NotNullWhen (true)] out Stream? stream) + { + CallCount++; + stream = CallCount == 1 ? new FaultingStream () : new MemoryStream (content); + return true; + } + } + + sealed class FaultingStream : MemoryStream + { + public override void CopyTo (Stream destination, int bufferSize) + { + destination.WriteByte (1); + throw new IOException ("Simulated interrupted Maven download."); + } + + public override async Task CopyToAsync (Stream destination, int bufferSize, CancellationToken cancellationToken) + { + await destination.WriteAsync (new byte [] { 1 }, 0, 1, cancellationToken); + throw new IOException ("Simulated interrupted Maven download."); + } + } + + sealed class PublishingStream : MemoryStream + { + readonly string path; + readonly byte [] content; + + public PublishingStream (string path, byte [] content) + : base (content) + { + this.path = path; + this.content = content; + } + + public override async Task CopyToAsync (Stream destination, int bufferSize, CancellationToken cancellationToken) + { + await base.CopyToAsync (destination, bufferSize, cancellationToken); + File.WriteAllBytes (path, content); + } + } }