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" +```