diff --git a/src/Microsoft.ComponentDetection.Common/FastDirectoryWalkerFactory.cs b/src/Microsoft.ComponentDetection.Common/FastDirectoryWalkerFactory.cs index a1f2fa2e5..efc0f73b8 100644 --- a/src/Microsoft.ComponentDetection.Common/FastDirectoryWalkerFactory.cs +++ b/src/Microsoft.ComponentDetection.Common/FastDirectoryWalkerFactory.cs @@ -142,19 +142,26 @@ public IObservable GetDirectoryScanner(DirectoryInfo root, Concu var scan = new ActionBlock( di => { - var enumerator = new FileSystemEnumerable(di.FullName, this.Transform, new EnumerationOptions() + try { - RecurseSubdirectories = true, - IgnoreInaccessible = true, - ReturnSpecialDirectories = false, - }) - { - ShouldRecursePredicate = shouldRecurse, - }; - - foreach (var fileSystemInfo in enumerator) + var enumerator = new FileSystemEnumerable(di.FullName, this.Transform, new EnumerationOptions() + { + RecurseSubdirectories = true, + IgnoreInaccessible = true, + ReturnSpecialDirectories = false, + }) + { + ShouldRecursePredicate = shouldRecurse, + }; + + foreach (var fileSystemInfo in enumerator) + { + observer.OnNext(fileSystemInfo); + } + } + catch (DirectoryNotFoundException) { - observer.OnNext(fileSystemInfo); + this.logger.LogDebug("Directory disappeared during enumeration: {DirectoryFullName}", di.FullName); } }, new ExecutionDataflowBlockOptions() { MaxDegreeOfParallelism = Environment.ProcessorCount }); @@ -184,6 +191,12 @@ public IObservable GetDirectoryScanner(DirectoryInfo root, Concu s.OnNext(info); }, + error => + { + sw.Stop(); + this.logger.LogError(error, "Directory enumeration failed for {RootFullName}", root.FullName); + s.OnError(error); + }, () => { sw.Stop(); diff --git a/test/Microsoft.ComponentDetection.Common.Tests/FastDirectoryWalkerFactoryTests.cs b/test/Microsoft.ComponentDetection.Common.Tests/FastDirectoryWalkerFactoryTests.cs new file mode 100644 index 000000000..d8b584785 --- /dev/null +++ b/test/Microsoft.ComponentDetection.Common.Tests/FastDirectoryWalkerFactoryTests.cs @@ -0,0 +1,123 @@ +#nullable disable +namespace Microsoft.ComponentDetection.Common.Tests; + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using AwesomeAssertions; +using Microsoft.ComponentDetection.Contracts; +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +[TestClass] +[TestCategory("Governance/All")] +[TestCategory("Governance/ComponentDetection")] +public class FastDirectoryWalkerFactoryTests +{ + private string temporaryDirectory; + + [TestInitialize] + public void TestInitialize() + { + this.temporaryDirectory = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(this.temporaryDirectory); + } + + [TestCleanup] + public void TestCleanup() + { + if (Directory.Exists(this.temporaryDirectory)) + { + Directory.Delete(this.temporaryDirectory, true); + } + } + + [TestMethod] + public async Task GetDirectoryScanner_CompletesWhenDiscoveredDirectoryDisappears() + { + var disappearingDirectory = Directory.CreateDirectory(Path.Combine(this.temporaryDirectory, "disappearing")); + await File.WriteAllTextAsync(Path.Combine(disappearingDirectory.FullName, "component.txt"), "content"); + + var directoryWasDeleted = false; + bool DeleteDiscoveredDirectory(ReadOnlySpan directoryName, ReadOnlySpan parentPath) + { + _ = directoryName; + _ = parentPath; + Directory.Delete(disappearingDirectory.FullName, true); + directoryWasDeleted = true; + return false; + } + + var walker = new FastDirectoryWalkerFactory( + Mock.Of(), + Mock.Of>()); + + var completion = new TaskCompletionSource>(TaskCreationOptions.RunContinuationsAsynchronously); + using var subscription = walker.GetDirectoryScanner( + new DirectoryInfo(this.temporaryDirectory), + new ConcurrentDictionary(), + DeleteDiscoveredDirectory).Subscribe(new RecordingObserver(completion)); + + var results = await completion.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + directoryWasDeleted.Should().BeTrue(); + results.Should().BeEmpty(); + } + + [TestMethod] + public async Task GetDirectoryScanner_PropagatesUnexpectedEnumerationError() + { + var parentDirectory = Directory.CreateDirectory(Path.Combine(this.temporaryDirectory, "parent")); + Directory.CreateDirectory(Path.Combine(parentDirectory.FullName, "nested")); + var expectedException = new InvalidOperationException("Unexpected enumeration error"); + + bool ThrowForNestedDirectory(ReadOnlySpan directoryName, ReadOnlySpan parentPath) + { + _ = parentPath; + + if (directoryName.SequenceEqual("nested")) + { + throw expectedException; + } + + return false; + } + + var logger = new Mock>(); + var walker = new FastDirectoryWalkerFactory( + Mock.Of(), + logger.Object); + + var completion = new TaskCompletionSource>(TaskCreationOptions.RunContinuationsAsynchronously); + using var subscription = walker.GetDirectoryScanner( + new DirectoryInfo(this.temporaryDirectory), + new ConcurrentDictionary(), + ThrowForNestedDirectory).Subscribe(new RecordingObserver(completion)); + + Func waitForCompletion = async () => await completion.Task.WaitAsync(TimeSpan.FromSeconds(5)); + var assertion = await waitForCompletion.Should().ThrowAsync(); + assertion.Which.Should().BeSameAs(expectedException); + logger.Verify( + x => x.Log( + LogLevel.Error, + It.IsAny(), + It.Is((value, _) => value.ToString().Contains("Directory enumeration failed")), + expectedException, + It.IsAny>()), + Times.Once); + } + + private sealed class RecordingObserver(TaskCompletionSource> completion) : IObserver + { + private readonly List results = []; + + public void OnCompleted() => completion.SetResult(this.results); + + public void OnError(Exception error) => completion.SetException(error); + + public void OnNext(FileSystemInfo value) => this.results.Add(value); + } +}