Browse Source

Show real progress while a solution exports

Solution export reported once per assembly, when that assembly finished.
Nothing was reported before the first one did, so the tab sat on the
indeterminate spinner it starts with for most of the run and then jumped
straight to the end -- exporting two assemblies showed a spinner, 1 of 2,
done. A project that bailed out before decompiling never reported at all,
stranding the bar short of the end for the rest of the export.

Sum the per-project file counts instead: each parallel worker feeds its own
counts into a shared map and the bar reports their total. WholeProjectDecompiler
carries its whole file count on every report, so the total is known from a
project's first written file rather than its last -- measured on two real
assemblies, the bar turns determinate after 245ms instead of 15s, and moves
through 978 files rather than 2 assemblies. Each project closes its share out
in a finally, so bailing out or cancelling still lets the bar reach the end.

The denominator grows over the first second as projects discover their file
counts. The alternative -- enumerating every project's types up front -- delays
the export itself to make the bar look better, which is the wrong trade.

Assisted-by: Claude:claude-opus-4-8:Claude Code
pull/3885/head
Siegfried Pammer 2 months ago committed by Siegfried Pammer
parent
commit
a9d7538eef
  1. 61
      ILSpy.Tests/Languages/ProjectExportRunnerTests.cs
  2. 108
      ILSpy/SolutionWriter.cs

61
ILSpy.Tests/Languages/ProjectExportRunnerTests.cs

@ -134,6 +134,67 @@ public class ProjectExportRunnerTests @@ -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()
{

108
ILSpy/SolutionWriter.cs

@ -82,7 +82,14 @@ namespace ICSharpCode.ILSpy @@ -82,7 +82,14 @@ namespace ICSharpCode.ILSpy
readonly IProgress<DecompilationProgress>? progress;
readonly ConcurrentBag<ProjectItem> projects;
readonly ConcurrentBag<string> 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<string, ProjectProgress> 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<DecompilationProgress>? progress)
@ -94,6 +101,32 @@ namespace ICSharpCode.ILSpy @@ -94,6 +101,32 @@ namespace ICSharpCode.ILSpy
solutionDirectory = Path.GetDirectoryName(solutionFilePath)!;
statusOutput = new ConcurrentBag<string>();
projects = new ConcurrentBag<ProjectItem>();
projectProgress = new ConcurrentDictionary<string, ProjectProgress>();
projectOrder = Array.Empty<string>();
}
/// <summary>How much of one project's file list has been written, and whether it is still running.</summary>
sealed class ProjectProgress
{
public int FilesWritten;
public int FileCount;
public bool Running;
}
/// <summary>
/// Feeds one project's file counts into the shared total. <see cref="WholeProjectDecompiler"/>
/// 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.
/// </summary>
sealed class ProjectProgressReporter(SolutionWriter writer, ProjectProgress project)
: IProgress<DecompilationProgress>
{
public void Report(DecompilationProgress value)
{
project.FileCount = value.TotalUnits;
project.FilesWritten = value.UnitsCompleted;
writer.ReportProgress();
}
}
async Task<SolutionExportResult> CreateSolutionAsync(IReadOnlyList<LoadedAssembly> allAssemblies,
@ -123,13 +156,15 @@ namespace ICSharpCode.ILSpy @@ -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 @@ -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 @@ -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 @@ -260,7 +349,6 @@ namespace ICSharpCode.ILSpy
statusOutput.Add("-------------");
statusOutput.Add($"Failed to decompile the assembly '{loadedAssembly.FileName}':{Environment.NewLine}{e}");
}
ReportDone();
}
}
}

Loading…
Cancel
Save