diff --git a/.github/workflows/build-ilspy.yml b/.github/workflows/build-ilspy.yml
index 8b786ab0e2..68865faee8 100644
--- a/.github/workflows/build-ilspy.yml
+++ b/.github/workflows/build-ilspy.yml
@@ -99,6 +99,13 @@ jobs:
- name: Execute unit tests
id: unit-tests
+ # All test hosts of the solution run concurrently on a 4-core runner. Server GC (enabled by
+ # ICSharpCode.Decompiler.Tests for machines it has to itself) sizes its heaps for the whole
+ # box and starves the neighbours: process-module walks in ILSpy.Tests.Windows stalled for
+ # 5-15 minutes, while the decompiler suite finished no faster than under workstation GC
+ # here. The environment variable overrides the runtimeconfig setting.
+ env:
+ DOTNET_gcServer: 0
run: >
dotnet test --solution ilspy.sln
--configuration ${{ matrix.configuration }}
diff --git a/ICSharpCode.Decompiler.Tests/CorrectnessTestRunner.cs b/ICSharpCode.Decompiler.Tests/CorrectnessTestRunner.cs
index 36f1d3981b..fd9068650b 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";
@@ -436,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
@@ -484,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
@@ -519,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
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)
diff --git a/ICSharpCode.Decompiler.Tests/Helpers/Tester.cs b/ICSharpCode.Decompiler.Tests/Helpers/Tester.cs
index c3f0a80768..7982ab5eb7 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);
}
- // 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);
+ catch when (all.Exception is { InnerExceptions.Count: > 1 })
+ {
+ // Surface every failed download/build, not just the first.
+ throw all.Exception;
+ }
+ }
+ 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);
}
@@ -1090,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);
@@ -1207,10 +1237,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)
diff --git a/ICSharpCode.Decompiler.Tests/ICSharpCode.Decompiler.Tests.csproj b/ICSharpCode.Decompiler.Tests/ICSharpCode.Decompiler.Tests.csproj
index e3fd7f88e5..c3a007f1f7 100644
--- a/ICSharpCode.Decompiler.Tests/ICSharpCode.Decompiler.Tests.csproj
+++ b/ICSharpCode.Decompiler.Tests/ICSharpCode.Decompiler.Tests.csproj
@@ -18,6 +18,12 @@
True
+
+ true
+
+ <_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..45a1a6d454 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"));
@@ -135,29 +137,40 @@ 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)
{
+ // 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)).GetAwaiter().GetResult(),
+ outputDir => Tester.RunAndCompareOutput(fileToRoundtrip, originalRun, 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)
+ 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";
@@ -220,7 +233,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)
diff --git a/ILSpy.Tests.Windows/Processes/NetFrameworkProcessesTests.cs b/ILSpy.Tests.Windows/Processes/NetFrameworkProcessesTests.cs
index b3982c4a79..185c9bfed9 100644
--- a/ILSpy.Tests.Windows/Processes/NetFrameworkProcessesTests.cs
+++ b/ILSpy.Tests.Windows/Processes/NetFrameworkProcessesTests.cs
@@ -55,8 +55,10 @@ public void StartADotNetFrameworkProcess()
File.Exists(WindowsPowerShellPath).Should().BeTrue(
"Windows PowerShell 5.1 is the .NET Framework process this fixture inspects");
+ // The host lives exactly as long as the test host process: no wall clock for a slow
+ // machine to outrun mid-fixture, and no orphan if the teardown never runs.
host = Process.Start(new ProcessStartInfo(WindowsPowerShellPath,
- "-NoProfile -NonInteractive -Command \"Start-Sleep -Seconds 300\"") {
+ $"-NoProfile -NonInteractive -Command \"Wait-Process -Id {Environment.ProcessId}\"") {
UseShellExecute = false,
CreateNoWindow = true,
});
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"
+```