Skip to content

Commit a9d7538

Browse files
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
1 parent 150fa30 commit a9d7538

2 files changed

Lines changed: 159 additions & 10 deletions

File tree

ILSpy.Tests/Languages/ProjectExportRunnerTests.cs

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,67 @@ public async Task Solution_Mode_Writes_Sln_And_Projects()
134134
}
135135
}
136136

137+
[AvaloniaTest]
138+
public async Task Solution_Progress_Is_Determinate_And_Counts_Files_Across_Projects()
139+
{
140+
var (_, vm) = await TestHarness.BootAsync();
141+
var assemblies = await OpenFixtures(vm, 2);
142+
143+
var tempDir = Path.Combine(Path.GetTempPath(), "ILSpyProjSlnProgress_" + System.Guid.NewGuid().ToString("N"));
144+
Directory.CreateDirectory(tempDir);
145+
try
146+
{
147+
var progress = new RecordingProgress();
148+
var result = await ProjectExporter.ExportAsync(assemblies, solutionMode: true, Options(tempDir),
149+
new DecompilerSettings(), Language(), progress, CancellationToken.None);
150+
result.Success.Should().BeTrue(result.StatusText);
151+
152+
progress.Reports.Should().NotBeEmpty();
153+
progress.Reports.Max(p => p.TotalUnits).Should().BeGreaterThan(assemblies.Count,
154+
"the bar counts the files of every project put together, not whole assemblies -- an assembly-granular "
155+
+ "bar only moves when a project finishes, which for a solution of two is 0%, 50%, done");
156+
progress.Reports.Should().Contain(p => p.TotalUnits > 0 && p.UnitsCompleted < p.TotalUnits,
157+
"a determinate report has to arrive while work is still outstanding; reporting only on completion "
158+
+ "leaves the tab showing an indeterminate spinner for the whole export");
159+
progress.Reports.Should().Contain(p => p.Status != null && p.Status.Contains(assemblies[0].ShortName),
160+
"the status names the projects being written");
161+
}
162+
finally
163+
{
164+
TryDelete(tempDir);
165+
}
166+
}
167+
168+
[AvaloniaTest]
169+
public async Task Solution_Progress_Completes_Even_When_A_Project_Cannot_Be_Written()
170+
{
171+
var (_, vm) = await TestHarness.BootAsync();
172+
var assemblies = await OpenFixtures(vm, 2);
173+
174+
var tempDir = Path.Combine(Path.GetTempPath(), "ILSpyProjSlnStuck_" + System.Guid.NewGuid().ToString("N"));
175+
Directory.CreateDirectory(tempDir);
176+
try
177+
{
178+
// A file where the second project's directory needs to go: that project bails out before it
179+
// writes anything, which used to leave its share of the bar outstanding forever.
180+
await File.WriteAllTextAsync(Path.Combine(tempDir, assemblies[1].ShortName), "in the way");
181+
182+
var progress = new RecordingProgress();
183+
var result = await ProjectExporter.ExportAsync(assemblies, solutionMode: true, Options(tempDir),
184+
new DecompilerSettings(), Language(), progress, CancellationToken.None);
185+
186+
result.Success.Should().BeFalse("a project that cannot be written is a failed export");
187+
progress.Reports.Should().NotBeEmpty();
188+
var last = progress.Reports[^1];
189+
last.UnitsCompleted.Should().Be(last.TotalUnits,
190+
"the bar has to close out when the export stops, even though one project never ran");
191+
}
192+
finally
193+
{
194+
TryDelete(tempDir);
195+
}
196+
}
197+
137198
[AvaloniaTest]
138199
public async Task Solution_Mode_Skips_Assemblies_That_Failed_To_Load()
139200
{

ILSpy/SolutionWriter.cs

Lines changed: 98 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,14 @@ public static Task<SolutionExportResult> CreateSolutionAsync(string solutionFile
8282
readonly IProgress<DecompilationProgress>? progress;
8383
readonly ConcurrentBag<ProjectItem> projects;
8484
readonly ConcurrentBag<string> statusOutput;
85-
int completedAssemblies;
85+
86+
// How far each project has got, keyed by assembly short name -- unique, because duplicate names
87+
// abort the export before any project runs. The workers fill these in as they decompile and the
88+
// progress bar shows their sum.
89+
readonly ConcurrentDictionary<string, ProjectProgress> projectProgress;
90+
// The projects in selection order, so the status label lists them in a stable order rather than
91+
// in whatever order the workers happen to reach them.
92+
string[] projectOrder;
8693

8794
SolutionWriter(string solutionFilePath, DecompilerSettings settings, string? strongNameKeyFile,
8895
IProgress<DecompilationProgress>? progress)
@@ -94,6 +101,32 @@ public static Task<SolutionExportResult> CreateSolutionAsync(string solutionFile
94101
solutionDirectory = Path.GetDirectoryName(solutionFilePath)!;
95102
statusOutput = new ConcurrentBag<string>();
96103
projects = new ConcurrentBag<ProjectItem>();
104+
projectProgress = new ConcurrentDictionary<string, ProjectProgress>();
105+
projectOrder = Array.Empty<string>();
106+
}
107+
108+
/// <summary>How much of one project's file list has been written, and whether it is still running.</summary>
109+
sealed class ProjectProgress
110+
{
111+
public int FilesWritten;
112+
public int FileCount;
113+
public bool Running;
114+
}
115+
116+
/// <summary>
117+
/// Feeds one project's file counts into the shared total. <see cref="WholeProjectDecompiler"/>
118+
/// reports its whole file count with every report, so the solution bar knows a project's size from
119+
/// its first written file rather than only once the project is done.
120+
/// </summary>
121+
sealed class ProjectProgressReporter(SolutionWriter writer, ProjectProgress project)
122+
: IProgress<DecompilationProgress>
123+
{
124+
public void Report(DecompilationProgress value)
125+
{
126+
project.FileCount = value.TotalUnits;
127+
project.FilesWritten = value.UnitsCompleted;
128+
writer.ReportProgress();
129+
}
97130
}
98131

99132
async Task<SolutionExportResult> CreateSolutionAsync(IReadOnlyList<LoadedAssembly> allAssemblies,
@@ -123,13 +156,15 @@ async Task<SolutionExportResult> CreateSolutionAsync(IReadOnlyList<LoadedAssembl
123156
if (abort)
124157
return new SolutionExportResult(false, report.ToString());
125158

159+
projectOrder = allAssemblies.Select(a => a.ShortName).ToArray();
160+
126161
try
127162
{
128163
// An explicit enumerable partitioner avoids Parallel.ForEach's list special-casing,
129164
// whose static partitioning is inefficient when assemblies decompile at different speeds.
130165
await Task.Run(() => System.Threading.Tasks.Parallel.ForEach(Partitioner.Create(allAssemblies),
131166
new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount, CancellationToken = ct },
132-
item => WriteProject(item, language, solutionDirectory, allAssemblies.Count, ct)))
167+
item => WriteProject(item, language, solutionDirectory, ct)))
133168
.ConfigureAwait(false);
134169

135170
if (projects.Count == 0)
@@ -183,15 +218,68 @@ await Task.Run(() => SolutionCreator.WriteSolutionFile(solutionFilePath, project
183218
return new SolutionExportResult(true, report.ToString());
184219
}
185220

186-
void WriteProject(LoadedAssembly loadedAssembly, Language language, string targetDirectory, int totalAssemblies, CancellationToken ct)
221+
// Reports the whole solution's progress: the file counts of every project added up. The projects
222+
// are decompiled in parallel, so no single one of them can drive the bar; summing them lets it
223+
// move continuously and, because a project reports its file count as soon as it writes its first
224+
// file, turn determinate right after the export starts. Racing reads are fine here -- the worst a
225+
// report that races a worker can be is a file or two out of date.
226+
void ReportProgress()
187227
{
188-
// Solution export decompiles assemblies in parallel, so per-file progress would race; report
189-
// at the coarser assembly granularity instead -- a determinate bar over the assembly count.
190-
void ReportDone() => progress?.Report(new DecompilationProgress {
191-
TotalUnits = totalAssemblies,
192-
UnitsCompleted = System.Threading.Interlocked.Increment(ref completedAssemblies),
193-
Status = loadedAssembly.ShortName,
228+
if (progress == null)
229+
return;
230+
231+
int filesWritten = 0, fileCount = 0;
232+
foreach (var project in projectProgress.Values)
233+
{
234+
filesWritten += project.FilesWritten;
235+
fileCount += project.FileCount;
236+
}
237+
238+
progress.Report(new DecompilationProgress {
239+
TotalUnits = fileCount,
240+
UnitsCompleted = filesWritten,
241+
Title = "Exporting solution...",
242+
Status = RunningProjects(),
194243
});
244+
}
245+
246+
// The projects being written right now, so the label says what is running instead of naming
247+
// whichever project happened to report last. Long selections are cut short: the bar is not the
248+
// place to list twenty assemblies.
249+
string RunningProjects()
250+
{
251+
const int maxNames = 3;
252+
var running = projectOrder
253+
.Where(name => projectProgress.TryGetValue(name, out var project) && project.Running)
254+
.ToList();
255+
return running.Count <= maxNames
256+
? string.Join(", ", running)
257+
: string.Join(", ", running.Take(maxNames)) + $" and {running.Count - maxNames} more";
258+
}
259+
260+
void WriteProject(LoadedAssembly loadedAssembly, Language language, string targetDirectory, CancellationToken ct)
261+
{
262+
var project = new ProjectProgress { Running = true };
263+
projectProgress[loadedAssembly.ShortName] = project;
264+
ReportProgress();
265+
try
266+
{
267+
WriteProjectCore(loadedAssembly, language, targetDirectory, project, ct);
268+
}
269+
finally
270+
{
271+
// Whatever became of the project -- written, bailed out before it started, or cancelled --
272+
// it stops counting against the total here. Leaving an abandoned project's files
273+
// outstanding would strand the bar short of the end for the rest of the export.
274+
project.FilesWritten = project.FileCount;
275+
project.Running = false;
276+
ReportProgress();
277+
}
278+
}
279+
280+
void WriteProjectCore(LoadedAssembly loadedAssembly, Language language, string targetDirectory,
281+
ProjectProgress project, CancellationToken ct)
282+
{
195283
targetDirectory = Path.Combine(targetDirectory, loadedAssembly.ShortName);
196284

197285
if (language.ProjectFileExtension == null)
@@ -230,6 +318,7 @@ void ReportDone() => progress?.Report(new DecompilationProgress {
230318
options.CancellationToken = ct;
231319
options.SaveAsProjectDirectory = targetDirectory;
232320
options.StrongNameKeyFile = strongNameKeyFile;
321+
options.ProgressIndicator = new ProjectProgressReporter(this, project);
233322

234323
// The project-export path writes the .csproj into SaveAsProjectDirectory itself; the
235324
// ITextOutput only receives a "Project written to ..." breadcrumb, which we discard here.
@@ -260,7 +349,6 @@ void ReportDone() => progress?.Report(new DecompilationProgress {
260349
statusOutput.Add("-------------");
261350
statusOutput.Add($"Failed to decompile the assembly '{loadedAssembly.FileName}':{Environment.NewLine}{e}");
262351
}
263-
ReportDone();
264352
}
265353
}
266354
}

0 commit comments

Comments
 (0)