diff --git a/ILSpy.Tests/Languages/ProjectExportRunnerTests.cs b/ILSpy.Tests/Languages/ProjectExportRunnerTests.cs index a4ab7d6ba..942a970e9 100644 --- a/ILSpy.Tests/Languages/ProjectExportRunnerTests.cs +++ b/ILSpy.Tests/Languages/ProjectExportRunnerTests.cs @@ -134,6 +134,67 @@ public class ProjectExportRunnerTests } } + [AvaloniaTest] + public async Task Solution_Progress_Is_Determinate_And_Counts_Files_Across_Projects() + { + var (_, vm) = await TestHarness.BootAsync(); + var assemblies = await OpenFixtures(vm, 2); + + var tempDir = Path.Combine(Path.GetTempPath(), "ILSpyProjSlnProgress_" + System.Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + try + { + var progress = new RecordingProgress(); + var result = await ProjectExporter.ExportAsync(assemblies, solutionMode: true, Options(tempDir), + new DecompilerSettings(), Language(), progress, CancellationToken.None); + result.Success.Should().BeTrue(result.StatusText); + + progress.Reports.Should().NotBeEmpty(); + progress.Reports.Max(p => p.TotalUnits).Should().BeGreaterThan(assemblies.Count, + "the bar counts the files of every project put together, not whole assemblies -- an assembly-granular " + + "bar only moves when a project finishes, which for a solution of two is 0%, 50%, done"); + progress.Reports.Should().Contain(p => p.TotalUnits > 0 && p.UnitsCompleted < p.TotalUnits, + "a determinate report has to arrive while work is still outstanding; reporting only on completion " + + "leaves the tab showing an indeterminate spinner for the whole export"); + progress.Reports.Should().Contain(p => p.Status != null && p.Status.Contains(assemblies[0].ShortName), + "the status names the projects being written"); + } + finally + { + TryDelete(tempDir); + } + } + + [AvaloniaTest] + public async Task Solution_Progress_Completes_Even_When_A_Project_Cannot_Be_Written() + { + var (_, vm) = await TestHarness.BootAsync(); + var assemblies = await OpenFixtures(vm, 2); + + var tempDir = Path.Combine(Path.GetTempPath(), "ILSpyProjSlnStuck_" + System.Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + try + { + // A file where the second project's directory needs to go: that project bails out before it + // writes anything, which used to leave its share of the bar outstanding forever. + await File.WriteAllTextAsync(Path.Combine(tempDir, assemblies[1].ShortName), "in the way"); + + var progress = new RecordingProgress(); + var result = await ProjectExporter.ExportAsync(assemblies, solutionMode: true, Options(tempDir), + new DecompilerSettings(), Language(), progress, CancellationToken.None); + + result.Success.Should().BeFalse("a project that cannot be written is a failed export"); + progress.Reports.Should().NotBeEmpty(); + var last = progress.Reports[^1]; + last.UnitsCompleted.Should().Be(last.TotalUnits, + "the bar has to close out when the export stops, even though one project never ran"); + } + finally + { + TryDelete(tempDir); + } + } + [AvaloniaTest] public async Task Solution_Mode_Skips_Assemblies_That_Failed_To_Load() { diff --git a/ILSpy/SolutionWriter.cs b/ILSpy/SolutionWriter.cs index 81c931fde..3ebe1f2e4 100644 --- a/ILSpy/SolutionWriter.cs +++ b/ILSpy/SolutionWriter.cs @@ -82,7 +82,14 @@ namespace ICSharpCode.ILSpy readonly IProgress? progress; readonly ConcurrentBag projects; readonly ConcurrentBag statusOutput; - int completedAssemblies; + + // How far each project has got, keyed by assembly short name -- unique, because duplicate names + // abort the export before any project runs. The workers fill these in as they decompile and the + // progress bar shows their sum. + readonly ConcurrentDictionary projectProgress; + // The projects in selection order, so the status label lists them in a stable order rather than + // in whatever order the workers happen to reach them. + string[] projectOrder; SolutionWriter(string solutionFilePath, DecompilerSettings settings, string? strongNameKeyFile, IProgress? progress) @@ -94,6 +101,32 @@ namespace ICSharpCode.ILSpy solutionDirectory = Path.GetDirectoryName(solutionFilePath)!; statusOutput = new ConcurrentBag(); projects = new ConcurrentBag(); + projectProgress = new ConcurrentDictionary(); + projectOrder = Array.Empty(); + } + + /// How much of one project's file list has been written, and whether it is still running. + sealed class ProjectProgress + { + public int FilesWritten; + public int FileCount; + public bool Running; + } + + /// + /// Feeds one project's file counts into the shared total. + /// reports its whole file count with every report, so the solution bar knows a project's size from + /// its first written file rather than only once the project is done. + /// + sealed class ProjectProgressReporter(SolutionWriter writer, ProjectProgress project) + : IProgress + { + public void Report(DecompilationProgress value) + { + project.FileCount = value.TotalUnits; + project.FilesWritten = value.UnitsCompleted; + writer.ReportProgress(); + } } async Task CreateSolutionAsync(IReadOnlyList allAssemblies, @@ -123,13 +156,15 @@ namespace ICSharpCode.ILSpy if (abort) return new SolutionExportResult(false, report.ToString()); + projectOrder = allAssemblies.Select(a => a.ShortName).ToArray(); + try { // An explicit enumerable partitioner avoids Parallel.ForEach's list special-casing, // whose static partitioning is inefficient when assemblies decompile at different speeds. await Task.Run(() => System.Threading.Tasks.Parallel.ForEach(Partitioner.Create(allAssemblies), new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount, CancellationToken = ct }, - item => WriteProject(item, language, solutionDirectory, allAssemblies.Count, ct))) + item => WriteProject(item, language, solutionDirectory, ct))) .ConfigureAwait(false); if (projects.Count == 0) @@ -183,15 +218,68 @@ namespace ICSharpCode.ILSpy return new SolutionExportResult(true, report.ToString()); } - void WriteProject(LoadedAssembly loadedAssembly, Language language, string targetDirectory, int totalAssemblies, CancellationToken ct) + // Reports the whole solution's progress: the file counts of every project added up. The projects + // are decompiled in parallel, so no single one of them can drive the bar; summing them lets it + // move continuously and, because a project reports its file count as soon as it writes its first + // file, turn determinate right after the export starts. Racing reads are fine here -- the worst a + // report that races a worker can be is a file or two out of date. + void ReportProgress() { - // Solution export decompiles assemblies in parallel, so per-file progress would race; report - // at the coarser assembly granularity instead -- a determinate bar over the assembly count. - void ReportDone() => progress?.Report(new DecompilationProgress { - TotalUnits = totalAssemblies, - UnitsCompleted = System.Threading.Interlocked.Increment(ref completedAssemblies), - Status = loadedAssembly.ShortName, + if (progress == null) + return; + + int filesWritten = 0, fileCount = 0; + foreach (var project in projectProgress.Values) + { + filesWritten += project.FilesWritten; + fileCount += project.FileCount; + } + + progress.Report(new DecompilationProgress { + TotalUnits = fileCount, + UnitsCompleted = filesWritten, + Title = "Exporting solution...", + Status = RunningProjects(), }); + } + + // The projects being written right now, so the label says what is running instead of naming + // whichever project happened to report last. Long selections are cut short: the bar is not the + // place to list twenty assemblies. + string RunningProjects() + { + const int maxNames = 3; + var running = projectOrder + .Where(name => projectProgress.TryGetValue(name, out var project) && project.Running) + .ToList(); + return running.Count <= maxNames + ? string.Join(", ", running) + : string.Join(", ", running.Take(maxNames)) + $" and {running.Count - maxNames} more"; + } + + void WriteProject(LoadedAssembly loadedAssembly, Language language, string targetDirectory, CancellationToken ct) + { + var project = new ProjectProgress { Running = true }; + projectProgress[loadedAssembly.ShortName] = project; + ReportProgress(); + try + { + WriteProjectCore(loadedAssembly, language, targetDirectory, project, ct); + } + finally + { + // Whatever became of the project -- written, bailed out before it started, or cancelled -- + // it stops counting against the total here. Leaving an abandoned project's files + // outstanding would strand the bar short of the end for the rest of the export. + project.FilesWritten = project.FileCount; + project.Running = false; + ReportProgress(); + } + } + + void WriteProjectCore(LoadedAssembly loadedAssembly, Language language, string targetDirectory, + ProjectProgress project, CancellationToken ct) + { targetDirectory = Path.Combine(targetDirectory, loadedAssembly.ShortName); if (language.ProjectFileExtension == null) @@ -230,6 +318,7 @@ namespace ICSharpCode.ILSpy options.CancellationToken = ct; options.SaveAsProjectDirectory = targetDirectory; options.StrongNameKeyFile = strongNameKeyFile; + options.ProgressIndicator = new ProjectProgressReporter(this, project); // The project-export path writes the .csproj into SaveAsProjectDirectory itself; the // ITextOutput only receives a "Project written to ..." breadcrumb, which we discard here. @@ -260,7 +349,6 @@ namespace ICSharpCode.ILSpy statusOutput.Add("-------------"); statusOutput.Add($"Failed to decompile the assembly '{loadedAssembly.FileName}':{Environment.NewLine}{e}"); } - ReportDone(); } } }