From b53a3983f858e58adc157b6206ef4870df06025e Mon Sep 17 00:00:00 2001 From: Christoph Wille Date: Thu, 30 Jul 2026 13:23:07 +0200 Subject: [PATCH 01/15] Increase test-suite CPU utilization via NUnit parallelism A full ICSharpCode.Decompiler.Tests run kept a 24-logical-CPU machine at only ~46% average CPU: unattributed fixtures ran one at a time on NUnit's non-parallel queue, the default one-worker-per-CPU pool sat blocked on child compiler/runner processes, and the multi-minute roundtrip and correctness tests straggled at the end of the run. Fixtures now run in parallel by default, the worker count is generated at build time as 2x the building machine's logical CPUs (LevelOfParallelism only accepts a constant, and a checked-in number would be wrong on every other machine), and the two heavyweight fixtures are ordered first so the longest tests start immediately. In-flight tests measured 47-48 instead of 24; the suite is now bounded by its single longest test rather than by scheduling. Assisted-by: Claude:claude-fable-5:Claude Code --- ICSharpCode.Decompiler.Tests/CorrectnessTestRunner.cs | 4 +++- .../ICSharpCode.Decompiler.Tests.csproj | 11 +++++++++++ .../Properties/AssemblyInfo.cs | 7 +++++++ ICSharpCode.Decompiler.Tests/RoundtripAssembly.cs | 4 +++- 4 files changed, 24 insertions(+), 2 deletions(-) diff --git a/ICSharpCode.Decompiler.Tests/CorrectnessTestRunner.cs b/ICSharpCode.Decompiler.Tests/CorrectnessTestRunner.cs index 36f1d3981b..18e65809b3 100644 --- a/ICSharpCode.Decompiler.Tests/CorrectnessTestRunner.cs +++ b/ICSharpCode.Decompiler.Tests/CorrectnessTestRunner.cs @@ -28,7 +28,9 @@ namespace ICSharpCode.Decompiler.Tests { - [TestFixture, Parallelizable(ParallelScope.All)] + // Order(2) enqueues these long compile+execute tests right after the roundtrip fixture, + // ahead of unordered fixtures, so they do not straggle at the end of a parallel run. + [TestFixture, Parallelizable(ParallelScope.All), Order(2)] public class CorrectnessTestRunner { static readonly string TestCasePath = Tester.TestCasePath + "/Correctness"; diff --git a/ICSharpCode.Decompiler.Tests/ICSharpCode.Decompiler.Tests.csproj b/ICSharpCode.Decompiler.Tests/ICSharpCode.Decompiler.Tests.csproj index e3fd7f88e5..c663442b5b 100644 --- a/ICSharpCode.Decompiler.Tests/ICSharpCode.Decompiler.Tests.csproj +++ b/ICSharpCode.Decompiler.Tests/ICSharpCode.Decompiler.Tests.csproj @@ -58,6 +58,17 @@ TRACE;$(DefineConstants) + + + + <_Parameter1>$([MSBuild]::Multiply($([System.Environment]::ProcessorCount), 2)) + <_Parameter1_TypeName>System.Int32 + + + diff --git a/ICSharpCode.Decompiler.Tests/Properties/AssemblyInfo.cs b/ICSharpCode.Decompiler.Tests/Properties/AssemblyInfo.cs index 83cb8afd73..a868a074aa 100644 --- a/ICSharpCode.Decompiler.Tests/Properties/AssemblyInfo.cs +++ b/ICSharpCode.Decompiler.Tests/Properties/AssemblyInfo.cs @@ -22,8 +22,15 @@ using System.Reflection; using System.Runtime.InteropServices; +using NUnit.Framework; + #endregion +// Fixtures without their own Parallelizable attribute run concurrently with other fixtures; +// tests within such a fixture still run sequentially. Fixtures that share process-global +// state must opt out individually with [NonParallelizable]. +[assembly: Parallelizable(ParallelScope.Fixtures)] + [assembly: AssemblyTrademark("")] [assembly: AssemblyCulture("")] diff --git a/ICSharpCode.Decompiler.Tests/RoundtripAssembly.cs b/ICSharpCode.Decompiler.Tests/RoundtripAssembly.cs index 56cc26fd68..06259d51e7 100644 --- a/ICSharpCode.Decompiler.Tests/RoundtripAssembly.cs +++ b/ICSharpCode.Decompiler.Tests/RoundtripAssembly.cs @@ -37,7 +37,9 @@ namespace ICSharpCode.Decompiler.Roundtrip { - [TestFixture, Parallelizable(ParallelScope.All), Platform("Win")] + // Order(1) enqueues this fixture's multi-minute roundtrip tests ahead of unordered + // fixtures, so they do not straggle at the end of a parallel run. + [TestFixture, Parallelizable(ParallelScope.All), Platform("Win"), Order(1)] public class RoundtripAssembly { public static readonly string TestDir = Path.GetFullPath(Path.Combine(Tester.TestCasePath, "../../ILSpy-tests")); From 81d0d7beec88a4c74e2aa619cff89353dbe632ed Mon Sep 17 00:00:00 2001 From: Christoph Wille Date: Thu, 30 Jul 2026 13:30:23 +0200 Subject: [PATCH 02/15] Document Windows Defender exclusions for test runs While the decompiler test suite runs, Defender's scan engine was measured using 1-4 CPU cores continuously and adds scan latency to every spawned compiler/runner process. Machine-level AV configuration does not belong in the repo, so document the folders worth excluding, the tradeoff, and the commands instead of automating the change. Assisted-by: Claude:claude-fable-5:Claude Code --- doc/WindowsDefenderExclusions.md | 60 ++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 doc/WindowsDefenderExclusions.md diff --git a/doc/WindowsDefenderExclusions.md b/doc/WindowsDefenderExclusions.md new file mode 100644 index 0000000000..94028410e7 --- /dev/null +++ b/doc/WindowsDefenderExclusions.md @@ -0,0 +1,60 @@ +# Windows Defender exclusions for test runs + +Real-time protection scans every file the test suite writes and every process it +spawns. A full `ICSharpCode.Decompiler.Tests` run compiles thousands of fixture +assemblies and launches csc/vbc/ilasm/msbuild/test-runner child processes; while it +runs, `MsMpEng.exe` (Defender's scan engine) has been measured using 1-4 CPU cores +continuously, and each process start pays additional scan latency. Excluding the +folders below removes that overhead. + +**Security tradeoff:** excluded folders are not scanned at all. Only add exclusions +on a development machine you control, and only for paths that contain code you +build yourself. If you use a third-party antivirus or backup suite with real-time +scanning (e.g. Acronis Active Protection), configure equivalent exclusions there. + +## Folders to exclude + +1. **The ILSpy repository clone** (e.g. `C:\src\ILSpy`). This covers everything the + build and tests write inside the repo: + - `bin\` and `obj\` of every project + - compiled test fixtures placed next to their sources under + `ICSharpCode.Decompiler.Tests\TestCases\` + - the Roslyn toolsets, reference-assembly packs, and vswhere that + `Tester.Initialize()` downloads under the test output directory + - the `ILSpy-tests\` submodule, including the `*-decompiled` / `*-output` + folders the roundtrip tests generate next to their inputs +2. **The test-assembly temp path, if you redirected it.** When + `ICSharpCode.Decompiler.Tests\DecompilerTests.config.json` sets + `TestsAssemblyTempPath`, compiled fixtures land there instead of inside the + repo - exclude that folder as well. +3. **Optional: the NuGet package cache** (`%USERPROFILE%\.nuget\packages`). Only + restore performance benefits; packages are signed and hash-verified by NuGet, + but skip this one if you prefer scanned downloads. + +The user temp folder (`%TEMP%`) also receives small diff files from failing +correctness tests, but excluding all of `%TEMP%` is a poor tradeoff - malware +routinely stages there. Leave it scanned. + +## Adding the exclusions + +Run PowerShell **as Administrator**, adjusting the paths to your clone: + +```powershell +Add-MpPreference -ExclusionPath "C:\src\ILSpy" +# only if TestsAssemblyTempPath is configured: +Add-MpPreference -ExclusionPath "D:\ILSpyTestAssemblies" +# optional: +Add-MpPreference -ExclusionPath "$env:USERPROFILE\.nuget\packages" +``` + +## Verifying + +```powershell +Get-MpPreference | Select-Object -ExpandProperty ExclusionPath +``` + +## Removing + +```powershell +Remove-MpPreference -ExclusionPath "C:\src\ILSpy" +``` From 8344c52d02ccf17f23bfe03819d8a1b5b4ba6986 Mon Sep 17 00:00:00 2001 From: Christoph Wille Date: Tue, 11 Aug 2026 10:03:06 +0200 Subject: [PATCH 03/15] Synchronize toolset registration dictionaries Tester.Initialize is about to issue the toolset Fetch calls concurrently; each Fetch ends by registering its install path in a plain Dictionary, which is not safe for concurrent writers. Lookups need no lock: they only happen after Initialize has awaited all registrations. Assisted-by: Claude:claude-fable-5:Claude Code --- .../Helpers/RoslynToolset.cs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/ICSharpCode.Decompiler.Tests/Helpers/RoslynToolset.cs b/ICSharpCode.Decompiler.Tests/Helpers/RoslynToolset.cs index ec251070fe..0208298596 100644 --- a/ICSharpCode.Decompiler.Tests/Helpers/RoslynToolset.cs +++ b/ICSharpCode.Decompiler.Tests/Helpers/RoslynToolset.cs @@ -127,6 +127,8 @@ await packageReader.CopyFilesAsync(outputPath, files, class RoslynToolset : AbstractToolset { + // Registrations run concurrently while Tester.Initialize awaits all Fetch calls; + // lookups only happen after Initialize completes, so the read paths stay lock-free. readonly Dictionary installedCompilers = new Dictionary { { "legacy", Environment.ExpandEnvironmentVariables(@"%WINDIR%\Microsoft.NET\Framework\v4.0.30319") } }; @@ -144,7 +146,10 @@ public async Task Fetch(string version, string packageName = "Microsoft.Net.Comp await FetchPackage(packageName, version, sourcePath, Path.Combine(baseDir, version)).ConfigureAwait(false); } - installedCompilers.Add(SanitizeVersion(version), path); + lock (installedCompilers) + { + installedCompilers.Add(SanitizeVersion(version), path); + } } // In the .NET ("netcore") build of the compiler toolset the executables live in a @@ -218,6 +223,8 @@ public async Task Fetch() class RefAssembliesToolset : AbstractToolset { + // Registrations run concurrently while Tester.Initialize awaits all Fetch calls; + // lookups only happen after Initialize completes, so the read paths stay lock-free. readonly Dictionary installedFrameworks = new Dictionary { { "legacy", Path.Combine(Roundtrip.RoundtripAssembly.TestDir, "dotnet", "legacy") }, { "2.2.0", Path.Combine(Roundtrip.RoundtripAssembly.TestDir, "dotnet", "netcore-2.2") }, @@ -236,7 +243,10 @@ public async Task Fetch(string version, string packageName = "Microsoft.NETCore. await FetchPackage(packageName, version, sourcePath, Path.Combine(baseDir, version)).ConfigureAwait(false); } - installedFrameworks.Add(RoslynToolset.SanitizeVersion(version), path); + lock (installedFrameworks) + { + installedFrameworks.Add(RoslynToolset.SanitizeVersion(version), path); + } } internal string GetPath(string targetFramework) From 5d07943cf52a8e6be8137baf8bf1121ec78f80c5 Mon Sep 17 00:00:00 2001 From: Christoph Wille Date: Tue, 11 Aug 2026 10:03:35 +0200 Subject: [PATCH 04/15] Overlap toolset downloads and TestRunner builds in Tester.Initialize The setup fixture gates every test in the suite, and on a cold machine it serialized nine NuGet fetches plus two self-contained TestRunner builds. The fetches extract into disjoint directories and the builds depend on no fetched toolset, so everything now runs concurrently and is awaited once. Only the two Windows RID builds stay sequential with each other: they share the TestRunner project's obj/ directory, and their implicit restores would race on project.assets.json. Assisted-by: Claude:claude-fable-5:Claude Code --- .../Helpers/Tester.cs | 55 ++++++++++++------- 1 file changed, 36 insertions(+), 19 deletions(-) diff --git a/ICSharpCode.Decompiler.Tests/Helpers/Tester.cs b/ICSharpCode.Decompiler.Tests/Helpers/Tester.cs index c3f0a80768..4b7140d050 100644 --- a/ICSharpCode.Decompiler.Tests/Helpers/Tester.cs +++ b/ICSharpCode.Decompiler.Tests/Helpers/Tester.cs @@ -143,30 +143,44 @@ static Tester() internal static async Task Initialize() { - await roslynToolset.Fetch("1.3.2", "Microsoft.Net.Compilers", "tools").ConfigureAwait(false); - if (OperatingSystem.IsWindows()) - { - await roslynToolset.Fetch("2.10.0", "Microsoft.Net.Compilers", "tools").ConfigureAwait(false); - } - else - { + // All fetches download/extract into disjoint directories and the TestRunner builds + // do not depend on any fetched toolset, so everything runs concurrently and is + // awaited in one place. Individual toolset registrations are synchronized inside + // the toolsets (see RoslynToolset.cs). + var tasks = new List { + roslynToolset.Fetch("1.3.2", "Microsoft.Net.Compilers", "tools"), // Microsoft.Net.Compilers only ships .NET Framework executables. The sibling // Microsoft.NETCore.Compilers package contains the dotnet-hosted build of the // same compiler version (tools/bincore/csc.dll), usable on any platform. - await roslynToolset.Fetch("2.10.0", "Microsoft.NETCore.Compilers", "tools/bincore").ConfigureAwait(false); + OperatingSystem.IsWindows() + ? roslynToolset.Fetch("2.10.0", "Microsoft.Net.Compilers", "tools") + : roslynToolset.Fetch("2.10.0", "Microsoft.NETCore.Compilers", "tools/bincore"), + // On non-Windows hosts the net472 compiler binaries cannot be executed; use the + // .NET build of each toolset instead. Its tasks folder is named "netcoreapp3.1" + // up to Roslyn 3.x and "netcore" from Roslyn 4.x on. + roslynToolset.Fetch("3.11.0", sourcePath: OperatingSystem.IsWindows() ? "tasks/net472" : "tasks/netcoreapp3.1"), + roslynToolset.Fetch("4.14.0", sourcePath: OperatingSystem.IsWindows() ? "tasks/net472" : "tasks/netcore"), + roslynToolset.Fetch(roslynLatestVersion, sourcePath: OperatingSystem.IsWindows() ? "tasks/net472" : "tasks/netcore"), + vswhereToolset.Fetch(), + RefAssembliesToolset.Fetch("5.0.0", sourcePath: "ref/net5.0"), + RefAssembliesToolset.Fetch("9.0.0", sourcePath: "ref/net9.0"), + RefAssembliesToolset.Fetch(CurrentNetCoreRefAsmVersion, sourcePath: $"ref/net{CurrentNetCoreVersion}"), + BuildTestRunners(), + }; + Task all = Task.WhenAll(tasks); + try + { + await all.ConfigureAwait(false); + } + catch when (all.Exception is { InnerExceptions.Count: > 1 }) + { + // Surface every failed download/build, not just the first. + throw all.Exception; } - // On non-Windows hosts the net472 compiler binaries cannot be executed; use the - // .NET build of each toolset instead. Its tasks folder is named "netcoreapp3.1" - // up to Roslyn 3.x and "netcore" from Roslyn 4.x on. - await roslynToolset.Fetch("3.11.0", sourcePath: OperatingSystem.IsWindows() ? "tasks/net472" : "tasks/netcoreapp3.1").ConfigureAwait(false); - await roslynToolset.Fetch("4.14.0", sourcePath: OperatingSystem.IsWindows() ? "tasks/net472" : "tasks/netcore").ConfigureAwait(false); - await roslynToolset.Fetch(roslynLatestVersion, sourcePath: OperatingSystem.IsWindows() ? "tasks/net472" : "tasks/netcore").ConfigureAwait(false); - - await vswhereToolset.Fetch().ConfigureAwait(false); - await RefAssembliesToolset.Fetch("5.0.0", sourcePath: "ref/net5.0").ConfigureAwait(false); - await RefAssembliesToolset.Fetch("9.0.0", sourcePath: "ref/net9.0").ConfigureAwait(false); - await RefAssembliesToolset.Fetch(CurrentNetCoreRefAsmVersion, sourcePath: $"ref/net{CurrentNetCoreVersion}").ConfigureAwait(false); + } + static async Task BuildTestRunners() + { #if DEBUG const string testRunnerConfig = "Debug"; #else @@ -174,6 +188,9 @@ internal static async Task Initialize() #endif if (OperatingSystem.IsWindows()) { + // The two RID builds share the same project file and intermediate directory + // (obj/project.assets.json is written by each build's implicit restore), so + // they must not run concurrently with each other. await BuildTestRunner("win-x86", testRunnerConfig).ConfigureAwait(false); await BuildTestRunner("win-x64", testRunnerConfig).ConfigureAwait(false); } From d018267f86959dd433936d52c13bbf089796a0e6 Mon Sep 17 00:00:00 2001 From: Christoph Wille Date: Tue, 11 Aug 2026 10:05:45 +0200 Subject: [PATCH 05/15] Cache the vswhere-based MSBuild lookup Every roundtrip test spawned its own vswhere.exe to answer a question that is invariant for the lifetime of the process. Lazy> with ExecutionAndPublication guarantees a single spawn even when the parallel roundtrip fixture hits the lookup from several tests at once. Assisted-by: Claude:claude-fable-5:Claude Code --- ICSharpCode.Decompiler.Tests/Helpers/Tester.cs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/ICSharpCode.Decompiler.Tests/Helpers/Tester.cs b/ICSharpCode.Decompiler.Tests/Helpers/Tester.cs index 4b7140d050..c81c5f412b 100644 --- a/ICSharpCode.Decompiler.Tests/Helpers/Tester.cs +++ b/ICSharpCode.Decompiler.Tests/Helpers/Tester.cs @@ -1224,10 +1224,23 @@ public static async Task SignAssembly(string assemblyPath, string keyFilePath) } } - public static async Task FindMSBuild() + // Lazy> memoizes the vswhere lookup: the answer is invariant for the process, + // and the parallel roundtrip tests would otherwise each spawn their own vswhere.exe. + // A failed lookup stays cached, which is fine because a missing MSBuild is + // environmental, not transient. + static readonly Lazy> msbuildPath = new(FindMSBuildUncached, LazyThreadSafetyMode.ExecutionAndPublication); + + public static Task FindMSBuild() { + // The platform check stays outside the cache so that the IgnoreException is + // raised per test instead of being memoized as a faulted task. if (!OperatingSystem.IsWindows()) Assert.Ignore("FindMSBuild uses vswhere.exe to locate Visual Studio's MSBuild; not available on this platform."); + return msbuildPath.Value; + } + + static async Task FindMSBuildUncached() + { string path = vswhereToolset.GetVsWhere(); var result = await Cli.Wrap(path) From 66b0113ee059297c3d172deb5956f4ee999cfdd2 Mon Sep 17 00:00:00 2001 From: Christoph Wille Date: Tue, 11 Aug 2026 10:08:43 +0200 Subject: [PATCH 06/15] Run original and decompiled executables concurrently RunAndCompareOutput awaited the two runs back to back, but they are independent processes with separately buffered output. The new StartRun helper also lets callers begin the original run even earlier and hand the in-flight task to the comparison; it pre-observes the task fault so a run abandoned after an upstream failure cannot surface as an UnobservedTaskException. Plain WhenAll (no error aggregation) keeps NUnit Ignore semantics when both runs raise IgnoreException, and the exit codes are still asserted in the original order, so failure output is unchanged. Assisted-by: Claude:claude-fable-5:Claude Code --- .../Helpers/Tester.cs | 39 ++++++++++++------- 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/ICSharpCode.Decompiler.Tests/Helpers/Tester.cs b/ICSharpCode.Decompiler.Tests/Helpers/Tester.cs index c81c5f412b..7982ab5eb7 100644 --- a/ICSharpCode.Decompiler.Tests/Helpers/Tester.cs +++ b/ICSharpCode.Decompiler.Tests/Helpers/Tester.cs @@ -1107,21 +1107,34 @@ private static CSharpFormattingOptions CreateFormattingPolicyForTests() return formattingPolicy; } - public static async Task RunAndCompareOutput(string testFileName, string outputFile, string decompiledOutputFile, string decompiledCodeFile = null, bool useTestRunner = false, bool force32Bit = false) + /// + /// Starts executing the given assembly and returns the in-flight task, so that the run + /// can overlap other work (e.g. decompiling and recompiling the same assembly). The + /// task's fault is pre-observed: a caller that abandons the run because an earlier + /// pipeline stage failed first does not trigger UnobservedTaskException. + /// + public static Task<(int ExitCode, string Output, string Error)> StartRun(string assemblyFileName, bool useTestRunner = false, bool force32Bit = false) { - string output1, output2, error1, error2; - int result1, result2; + var task = useTestRunner ? RunWithTestRunner(assemblyFileName, force32Bit) : Run(assemblyFileName); + task.ContinueWith(static t => _ = t.Exception, CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); + return task; + } - if (useTestRunner) - { - (result1, output1, error1) = await RunWithTestRunner(outputFile, force32Bit).ConfigureAwait(false); - (result2, output2, error2) = await RunWithTestRunner(decompiledOutputFile, force32Bit).ConfigureAwait(false); - } - else - { - (result1, output1, error1) = await Run(outputFile).ConfigureAwait(false); - (result2, output2, error2) = await Run(decompiledOutputFile).ConfigureAwait(false); - } + public static Task RunAndCompareOutput(string testFileName, string outputFile, string decompiledOutputFile, string decompiledCodeFile = null, bool useTestRunner = false, bool force32Bit = false) + { + return RunAndCompareOutput(testFileName, StartRun(outputFile, useTestRunner, force32Bit), decompiledOutputFile, decompiledCodeFile, useTestRunner, force32Bit); + } + + public static async Task RunAndCompareOutput(string testFileName, Task<(int ExitCode, string Output, string Error)> originalRun, string decompiledOutputFile, string decompiledCodeFile = null, bool useTestRunner = false, bool force32Bit = false) + { + var decompiledRun = StartRun(decompiledOutputFile, useTestRunner, force32Bit); + // Plain WhenAll, no error aggregation: it observes both faults and rethrows the + // first one, which keeps NUnit's Ignore semantics intact when both runs raise + // IgnoreException (e.g. Force32Bit on a non-Windows host). + await Task.WhenAll(originalRun, decompiledRun).ConfigureAwait(false); + var (result1, output1, error1) = originalRun.Result; + var (result2, output2, error2) = decompiledRun.Result; Assert.That(result1, Is.EqualTo(0), "Exit code != 0; did the test case crash?" + Environment.NewLine + error1); Assert.That(result2, Is.EqualTo(0), "Exit code != 0; did the decompiled code crash?" + Environment.NewLine + error2); From e3e45cb2c36975c9305de11ebd1f7ad631be62de Mon Sep 17 00:00:00 2001 From: Christoph Wille Date: Tue, 11 Aug 2026 10:10:44 +0200 Subject: [PATCH 07/15] Start the original executable before decompiling in correctness tests The original binary is complete once the first compile (or ilasm) finishes, and the decompile/recompile stages only read it, so its execution now overlaps them instead of waiting at the very end of the pipeline. For mcs configurations the .exe.config write moves ahead of the run start - the runtime reads it at process launch - while the compiler-option mutation stays after the decompile, which must see the original options. Assisted-by: Claude:claude-fable-5:Claude Code --- .../CorrectnessTestRunner.cs | 39 ++++++++++++++----- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/ICSharpCode.Decompiler.Tests/CorrectnessTestRunner.cs b/ICSharpCode.Decompiler.Tests/CorrectnessTestRunner.cs index 18e65809b3..fd9068650b 100644 --- a/ICSharpCode.Decompiler.Tests/CorrectnessTestRunner.cs +++ b/ICSharpCode.Decompiler.Tests/CorrectnessTestRunner.cs @@ -438,30 +438,43 @@ async Task RunCS([CallerMemberName] string testName = null, CompilerOptions opti string testOutputFileName = TestsAssemblyOutput.GetFilePath(TestCasePath, testName, Tester.GetSuffix(options) + ".exe"); Helpers.CompilerResults outputFile = null, decompiledOutputFile = null; + // The mcs mutation below never touches these flags, so they can be captured here + // and used for the original run started before the mutation happens. + bool useTestRunner = (options & CompilerOptions.UseTestRunner) != 0; + bool force32Bit = (options & CompilerOptions.Force32Bit) != 0; + try { outputFile = await Tester.CompileCSharp(Path.Combine(TestCasePath, testFileName), options, outputFileName: testOutputFileName).ConfigureAwait(false); - string decompiledCodeFile = await Tester.DecompileCSharp(outputFile.PathToAssembly, Tester.GetSettings(options)).ConfigureAwait(false); if ((options & CompilerOptions.UseMcsMask) != 0) { - // For second pass, use roslyn instead of mcs. - // mcs has some compiler bugs that cause it to not accept ILSpy-generated code, - // for example when there's unreachable code due to other compiler bugs in the first mcs run. - options &= ~CompilerOptions.UseMcsMask; - options |= CompilerOptions.UseRoslynLatest; - // Also, add an .exe.config so that we consistently use the .NET 4.x runtime. + // Add an .exe.config so that we consistently use the .NET 4.x runtime. + // Written before the original executable starts below, because the runtime + // reads it at process start. File.WriteAllText(outputFile.PathToAssembly + ".config", @" "); + } + // The original executable is complete at this point; its run overlaps the + // decompile and recompile of the same assembly, which only read it. + var originalRun = Tester.StartRun(outputFile.PathToAssembly, useTestRunner, force32Bit); + string decompiledCodeFile = await Tester.DecompileCSharp(outputFile.PathToAssembly, Tester.GetSettings(options)).ConfigureAwait(false); + if ((options & CompilerOptions.UseMcsMask) != 0) + { + // For second pass, use roslyn instead of mcs. + // mcs has some compiler bugs that cause it to not accept ILSpy-generated code, + // for example when there's unreachable code due to other compiler bugs in the first mcs run. + options &= ~CompilerOptions.UseMcsMask; + options |= CompilerOptions.UseRoslynLatest; options |= CompilerOptions.TargetNet40; } decompiledOutputFile = await Tester.CompileCSharp(decompiledCodeFile, options).ConfigureAwait(false); - await Tester.RunAndCompareOutput(testFileName, outputFile.PathToAssembly, decompiledOutputFile.PathToAssembly, decompiledCodeFile, (options & CompilerOptions.UseTestRunner) != 0, (options & CompilerOptions.Force32Bit) != 0); + await Tester.RunAndCompareOutput(testFileName, originalRun, decompiledOutputFile.PathToAssembly, decompiledCodeFile, useTestRunner, force32Bit); Tester.RepeatOnIOError(() => File.Delete(decompiledCodeFile)); } finally @@ -486,10 +499,13 @@ async Task RunVB([CallerMemberName] string testName = null, CompilerOptions opti { outputFile = await Tester.CompileVB(Path.Combine(TestCasePath, testFileName), options, outputFileName: testOutputFileName).ConfigureAwait(false); + // The original executable is complete at this point; its run overlaps the + // decompile and recompile of the same assembly, which only read it. + var originalRun = Tester.StartRun(outputFile.PathToAssembly, (options & CompilerOptions.UseTestRunner) != 0, (options & CompilerOptions.Force32Bit) != 0); string decompiledCodeFile = await Tester.DecompileCSharp(outputFile.PathToAssembly, Tester.GetSettings(options)).ConfigureAwait(false); decompiledOutputFile = await Tester.CompileCSharp(decompiledCodeFile, options).ConfigureAwait(false); - await Tester.RunAndCompareOutput(testFileName, outputFile.PathToAssembly, decompiledOutputFile.PathToAssembly, decompiledCodeFile, (options & CompilerOptions.UseTestRunner) != 0, (options & CompilerOptions.Force32Bit) != 0); + await Tester.RunAndCompareOutput(testFileName, originalRun, decompiledOutputFile.PathToAssembly, decompiledCodeFile, (options & CompilerOptions.UseTestRunner) != 0, (options & CompilerOptions.Force32Bit) != 0); Tester.RepeatOnIOError(() => File.Delete(decompiledCodeFile)); } finally @@ -521,10 +537,13 @@ async Task RunIL(string testFileName, CompilerOptions options = CompilerOptions. options |= CompilerOptions.UseRoslynLatest; } outputFile = await Tester.AssembleIL(Path.Combine(TestCasePath, testFileName), asmOptions).ConfigureAwait(false); + // The original executable is complete at this point; its run overlaps the + // decompile and recompile of the same assembly, which only read it. + var originalRun = Tester.StartRun(outputFile, (options & CompilerOptions.UseTestRunner) != 0, (options & CompilerOptions.Force32Bit) != 0); string decompiledCodeFile = await Tester.DecompileCSharp(outputFile, Tester.GetSettings(options)).ConfigureAwait(false); decompiledOutputFile = await Tester.CompileCSharp(decompiledCodeFile, options).ConfigureAwait(false); - await Tester.RunAndCompareOutput(testFileName, outputFile, decompiledOutputFile.PathToAssembly, decompiledCodeFile, (options & CompilerOptions.UseTestRunner) != 0, (options & CompilerOptions.Force32Bit) != 0).ConfigureAwait(false); + await Tester.RunAndCompareOutput(testFileName, originalRun, decompiledOutputFile.PathToAssembly, decompiledCodeFile, (options & CompilerOptions.UseTestRunner) != 0, (options & CompilerOptions.Force32Bit) != 0).ConfigureAwait(false); Tester.RepeatOnIOError(() => File.Delete(decompiledCodeFile)); } finally From 0440e19b6e5d7ec8912488d804f7ae677cebc2cd Mon Sep 17 00:00:00 2001 From: Christoph Wille Date: Tue, 11 Aug 2026 10:12:48 +0200 Subject: [PATCH 08/15] Make the roundtrip testAction asynchronous The RunWithTest/RunWithOutput lambdas blocked an NUnit worker thread with GetAwaiter().GetResult() on inherently async work. Passing a Func lets RunInternal await the action, and enables handing an already-running execution into the comparison. Assisted-by: Claude:claude-fable-5:Claude Code --- ICSharpCode.Decompiler.Tests/RoundtripAssembly.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/ICSharpCode.Decompiler.Tests/RoundtripAssembly.cs b/ICSharpCode.Decompiler.Tests/RoundtripAssembly.cs index 06259d51e7..0adcba07ad 100644 --- a/ICSharpCode.Decompiler.Tests/RoundtripAssembly.cs +++ b/ICSharpCode.Decompiler.Tests/RoundtripAssembly.cs @@ -137,23 +137,23 @@ public async Task Random_TestCase_1_With_NativeInts() async Task RunWithTest(string dir, string fileToRoundtrip, string fileToTest, LanguageVersion languageVersion = defaultLanguageVersion, string keyFile = null, bool useOldProjectFormat = false) { - await RunInternal(dir, fileToRoundtrip, outputDir => RunTest(outputDir, fileToTest).GetAwaiter().GetResult(), languageVersion, snkFilePath: keyFile, useOldProjectFormat: useOldProjectFormat); + await RunInternal(dir, fileToRoundtrip, outputDir => RunTest(outputDir, fileToTest), languageVersion, snkFilePath: keyFile, useOldProjectFormat: useOldProjectFormat); } async Task RunWithOutput(string dir, string fileToRoundtrip, LanguageVersion languageVersion = defaultLanguageVersion) { string inputDir = Path.Combine(TestDir, dir); await RunInternal(dir, fileToRoundtrip, - outputDir => Tester.RunAndCompareOutput(fileToRoundtrip, Path.Combine(inputDir, fileToRoundtrip), Path.Combine(outputDir, fileToRoundtrip)).GetAwaiter().GetResult(), + outputDir => Tester.RunAndCompareOutput(fileToRoundtrip, Path.Combine(inputDir, fileToRoundtrip), Path.Combine(outputDir, fileToRoundtrip)), languageVersion); } async Task RunOnly(string dir, string fileToRoundtrip, LanguageVersion languageVersion = defaultLanguageVersion) { - await RunInternal(dir, fileToRoundtrip, outputDir => { }, languageVersion); + await RunInternal(dir, fileToRoundtrip, _ => Task.CompletedTask, languageVersion); } - async Task RunInternal(string dir, string fileToRoundtrip, Action testAction, LanguageVersion languageVersion, string snkFilePath = null, bool useOldProjectFormat = false) + async Task RunInternal(string dir, string fileToRoundtrip, Func testAction, LanguageVersion languageVersion, string snkFilePath = null, bool useOldProjectFormat = false) { if (!Directory.Exists(TestDir)) { @@ -222,7 +222,7 @@ async Task RunInternal(string dir, string fileToRoundtrip, Action testAc Assert.That(projectFile, Is.Not.Null, $"Could not find {fileToRoundtrip}"); await Compile(projectFile, outputDir); - testAction(outputDir); + await testAction(outputDir).ConfigureAwait(false); } static void ClearDirectory(string dir) From fe35a7222b43071f8c267eb0527b4114eb33e579 Mon Sep 17 00:00:00 2001 From: Christoph Wille Date: Tue, 11 Aug 2026 10:17:40 +0200 Subject: [PATCH 09/15] Overlap the pristine-executable run with the roundtrip pipeline In RunWithOutput roundtrip tests the reference executable from the ILSpy-tests checkout ran only after the whole-project decompile and the MSBuild rebuild had finished, although nothing in that pipeline writes to the input directory. Its execution now starts first and overlaps the multi-minute decompile. The submodule-missing guard moves ahead of the early start so those tests still report Ignored, not a faulted launch. Assisted-by: Claude:claude-fable-5:Claude Code --- ICSharpCode.Decompiler.Tests/RoundtripAssembly.cs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/ICSharpCode.Decompiler.Tests/RoundtripAssembly.cs b/ICSharpCode.Decompiler.Tests/RoundtripAssembly.cs index 0adcba07ad..45a1a6d454 100644 --- a/ICSharpCode.Decompiler.Tests/RoundtripAssembly.cs +++ b/ICSharpCode.Decompiler.Tests/RoundtripAssembly.cs @@ -142,9 +142,15 @@ async Task RunWithTest(string dir, string fileToRoundtrip, string fileToTest, La async Task RunWithOutput(string dir, string fileToRoundtrip, LanguageVersion languageVersion = defaultLanguageVersion) { + // Guard before starting the run, so a missing ILSpy-tests checkout still Ignores + // instead of faulting the eagerly started process launch. + EnsureTestDirAvailable(); string inputDir = Path.Combine(TestDir, dir); + // The pristine executable only reads from inputDir, which RunInternal never + // writes; its run overlaps the whole-project decompile and MSBuild rebuild. + var originalRun = Tester.StartRun(Path.Combine(inputDir, fileToRoundtrip)); await RunInternal(dir, fileToRoundtrip, - outputDir => Tester.RunAndCompareOutput(fileToRoundtrip, Path.Combine(inputDir, fileToRoundtrip), Path.Combine(outputDir, fileToRoundtrip)), + outputDir => Tester.RunAndCompareOutput(fileToRoundtrip, originalRun, Path.Combine(outputDir, fileToRoundtrip)), languageVersion); } @@ -153,13 +159,18 @@ async Task RunOnly(string dir, string fileToRoundtrip, LanguageVersion languageV await RunInternal(dir, fileToRoundtrip, _ => Task.CompletedTask, languageVersion); } - async Task RunInternal(string dir, string fileToRoundtrip, Func testAction, LanguageVersion languageVersion, string snkFilePath = null, bool useOldProjectFormat = false) + static void EnsureTestDirAvailable() { if (!Directory.Exists(TestDir)) { Assert.Ignore($"Assembly-roundtrip test ignored: test directory '{TestDir}' needs to be checked out separately." + Environment.NewLine + $"git clone https://github.com/icsharpcode/ILSpy-tests \"{TestDir}\""); } + } + + async Task RunInternal(string dir, string fileToRoundtrip, Func testAction, LanguageVersion languageVersion, string snkFilePath = null, bool useOldProjectFormat = false) + { + EnsureTestDirAvailable(); string inputDir = Path.Combine(TestDir, dir); string decompiledDir = inputDir + "-decompiled"; string outputDir = inputDir + "-output"; From 031dd04ab0a8572e7be2b2d79f21f4c677a10f27 Mon Sep 17 00:00:00 2001 From: Christoph Wille Date: Sat, 15 Aug 2026 11:29:43 +0200 Subject: [PATCH 10/15] Stop verifying the whole block after every block transform CheckInvariant is O(size of the block), and the block transforms merge statements into ever larger blocks, so checking after each one is quadratic in the body size. Normal code never notices, but a method whose statements all land in a single block does: decompiling ILSpy-tests' TestCase-1.exe (one 8,961-line Main) spent 19.5s of 22.4s inside 21,133 of these checks. That is what made Random_TestCase_1 the test suite's critical path in the Debug configuration -- the same decompile takes 2.0s in Release, where the check is compiled out. Dropping it is 8.3x on that fixture and 4-20% on ordinary assemblies, with output byte-identical. Two alternatives were measured and rejected: skipping the check only for oversized blocks costs an extra counting walk (7.3x, but ~4% slower elsewhere), and truncating the walk with a node budget still lets the derived overrides force flag computation (5.5x). The tree is still verified on entry, per statement inside StatementTransform, and per whole function in ILFunction.RunTransforms, so a corrupting transform is still caught in the same run -- only the attribution to an individual block transform is given up. Assisted-by: Claude:claude-opus-5[1m]:Claude Code --- ICSharpCode.Decompiler/IL/Instructions/Block.cs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/ICSharpCode.Decompiler/IL/Instructions/Block.cs b/ICSharpCode.Decompiler/IL/Instructions/Block.cs index 6c8206c46b..d023028163 100644 --- a/ICSharpCode.Decompiler/IL/Instructions/Block.cs +++ b/ICSharpCode.Decompiler/IL/Instructions/Block.cs @@ -356,6 +356,15 @@ public void Remove() /// /// Apply a list of transforms to this function. /// + /// + /// The block is verified once on entry, but deliberately not again after every single transform: + /// is O(size of the block), and the block transforms merge + /// statements into ever larger blocks, so a per-transform check is quadratic in the block size. + /// On a method whose statements all end up in one huge block that dominates the entire DEBUG + /// decompilation. The tree is still verified after each transform by + /// (per statement) and by + /// (whole function, after the enclosing IL transform). + /// public void RunTransforms(IEnumerable transforms, BlockTransformContext context) { this.CheckInvariant(ILPhase.Normal); @@ -365,7 +374,6 @@ public void RunTransforms(IEnumerable transforms, BlockTransfor Debug.Assert(context.IndexOfFirstAlreadyTransformedInstruction <= this.Instructions.Count); context.StepStartGroup(transform.GetType().Name); transform.Run(this, context); - this.CheckInvariant(ILPhase.Normal); context.StepEndGroup(); } } From 3125a61e05b19208a290d37e7a94221396361eed Mon Sep 17 00:00:00 2001 From: Christoph Wille Date: Sat, 15 Aug 2026 14:27:47 +0200 Subject: [PATCH 11/15] Revert "Stop verifying the whole block after every block transform" This reverts commit 42cdf006a53682ce0a569bcebcbaf78fdfde5be3. Measured on a 24-thread Windows box (two Debug runs each way), the removed check makes no difference to the decompiler test suite's wall time: the suite is bounded by the ExplicitConversions roundtrips at ~485 s both before and after, and in-suite decompiles run 20-25x slower than standalone at ~40% CPU, so the cost is contention, not this invariant check. The per-transform block check attributes a tree corruption to the individual block transform, which is worth keeping. Assisted-by: Claude:claude-fable-5:Claude Code --- ICSharpCode.Decompiler/IL/Instructions/Block.cs | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/ICSharpCode.Decompiler/IL/Instructions/Block.cs b/ICSharpCode.Decompiler/IL/Instructions/Block.cs index d023028163..6c8206c46b 100644 --- a/ICSharpCode.Decompiler/IL/Instructions/Block.cs +++ b/ICSharpCode.Decompiler/IL/Instructions/Block.cs @@ -356,15 +356,6 @@ public void Remove() /// /// Apply a list of transforms to this function. /// - /// - /// The block is verified once on entry, but deliberately not again after every single transform: - /// is O(size of the block), and the block transforms merge - /// statements into ever larger blocks, so a per-transform check is quadratic in the block size. - /// On a method whose statements all end up in one huge block that dominates the entire DEBUG - /// decompilation. The tree is still verified after each transform by - /// (per statement) and by - /// (whole function, after the enclosing IL transform). - /// public void RunTransforms(IEnumerable transforms, BlockTransformContext context) { this.CheckInvariant(ILPhase.Normal); @@ -374,6 +365,7 @@ public void RunTransforms(IEnumerable transforms, BlockTransfor Debug.Assert(context.IndexOfFirstAlreadyTransformedInstruction <= this.Instructions.Count); context.StepStartGroup(transform.GetType().Name); transform.Run(this, context); + this.CheckInvariant(ILPhase.Normal); context.StepEndGroup(); } } From 7ee67a62b0f0145ab6a2edbcb819c7e223feeac1 Mon Sep 17 00:00:00 2001 From: Christoph Wille Date: Sat, 15 Aug 2026 14:48:29 +0200 Subject: [PATCH 12/15] Use server GC for the decompiler test suite The suite keeps 2x logical CPUs NUnit workers busy with allocation-heavy decompiles (190 GB allocated per run), so under workstation GC every gen0/gen1 collection any worker triggers suspends the whole process. Measured on a 24-thread Windows box (Debug): 24,846 gen0 / 6,280 gen1 collections and 306 s of total GC pause in a 487 s run, at 44% average CPU. With server GC the same run takes 268 s, 978 gen0 / 427 gen1, 12 s of pause, 84% CPU; the in-suite whole-project decompiles drop 3-4x (ExplicitConversions 353-366 s -> 102-125 s, NRefactory 312 s -> 86 s). Standalone ilspycmd timings are unaffected, which is what pointed at contention inside the test process rather than decompiler cost. Assisted-by: Claude:claude-fable-5:Claude Code --- .../ICSharpCode.Decompiler.Tests.csproj | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/ICSharpCode.Decompiler.Tests/ICSharpCode.Decompiler.Tests.csproj b/ICSharpCode.Decompiler.Tests/ICSharpCode.Decompiler.Tests.csproj index c663442b5b..c3a007f1f7 100644 --- a/ICSharpCode.Decompiler.Tests/ICSharpCode.Decompiler.Tests.csproj +++ b/ICSharpCode.Decompiler.Tests/ICSharpCode.Decompiler.Tests.csproj @@ -18,6 +18,12 @@ True + + true