From 62745d687e6e933c34ca64b34d4c43ac7af048da Mon Sep 17 00:00:00 2001 From: Christoph Wille Date: Tue, 11 Aug 2026 10:03:06 +0200 Subject: [PATCH 1/7] 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 49f13d2dd75429723be74a3f571c8668e366ae9a Mon Sep 17 00:00:00 2001 From: Christoph Wille Date: Tue, 11 Aug 2026 10:03:35 +0200 Subject: [PATCH 2/7] 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 6b9d999aea2d427c2c4578528a3ec251eaca9cbe Mon Sep 17 00:00:00 2001 From: Christoph Wille Date: Tue, 11 Aug 2026 10:05:45 +0200 Subject: [PATCH 3/7] 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 f70038fffa916950da0724033a2b6ccb1b7d3bd8 Mon Sep 17 00:00:00 2001 From: Christoph Wille Date: Tue, 11 Aug 2026 10:08:43 +0200 Subject: [PATCH 4/7] 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 9cc89cfa7876052f2b61cf8d2cc295b5430bc8ef Mon Sep 17 00:00:00 2001 From: Christoph Wille Date: Tue, 11 Aug 2026 10:10:44 +0200 Subject: [PATCH 5/7] 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 36f1d3981b..de9b4720e2 100644 --- a/ICSharpCode.Decompiler.Tests/CorrectnessTestRunner.cs +++ b/ICSharpCode.Decompiler.Tests/CorrectnessTestRunner.cs @@ -436,30 +436,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 @@ -484,10 +497,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 @@ -519,10 +535,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 b5126ed5a44f684d9af8e67b4ced2e587859610f Mon Sep 17 00:00:00 2001 From: Christoph Wille Date: Tue, 11 Aug 2026 10:12:48 +0200 Subject: [PATCH 6/7] 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 56cc26fd68..1a0e1c8218 100644 --- a/ICSharpCode.Decompiler.Tests/RoundtripAssembly.cs +++ b/ICSharpCode.Decompiler.Tests/RoundtripAssembly.cs @@ -135,23 +135,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)) { @@ -220,7 +220,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 43b0d04880066af2bd976e425902feced37a6ef9 Mon Sep 17 00:00:00 2001 From: Christoph Wille Date: Tue, 11 Aug 2026 10:17:40 +0200 Subject: [PATCH 7/7] 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 1a0e1c8218..d6bf193cd9 100644 --- a/ICSharpCode.Decompiler.Tests/RoundtripAssembly.cs +++ b/ICSharpCode.Decompiler.Tests/RoundtripAssembly.cs @@ -140,9 +140,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); } @@ -151,13 +157,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";