diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 543250423..3b56c303f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -834,6 +834,8 @@ jobs: # Wall-clock performance tests are advisory — a slow shared runner must not # fail the build on a timing assertion. + # Throughput measurements run one fixture at a time: the suite is otherwise parallel + # (TestParallelism.cs), and concurrent fixtures would skew each other's timings. - name: Performance tests continue-on-error: true run: > @@ -841,6 +843,7 @@ jobs: --configuration Release --no-build --filter "Category=Performance" --logger "trx;LogFileName=perf-tests.trx" + -- NUnit.NumberOfTestWorkers=1 # AFTER every test leg, and it runs even when one of them failed. Ordered before them it # masks the suite completely: this step going red stopped the job, no test ran at all, and diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d4b21c43b..b03f9b08c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -36,6 +36,14 @@ The solution uses the `.slnx` XML solution format. `Directory.Build.props` appli ## Test + +Fixtures run in parallel (`Compression.Tests/TestParallelism.cs`); the tests inside one fixture +run in order. Keep every fixture independent: its own temp directory (`Guid`-named), its own +device-mapper or mount names, no fixed paths shared with another fixture, no process-wide +settings. A fixture that must change process-wide state (the current directory, an environment +variable, a global switch or observer) is marked `[NonParallelizable]` with the reason beside it. +Add `-- NUnit.NumberOfTestWorkers=1` to run the suite sequentially, as the performance step does. + ### Default tier The tier that must pass. It excludes every category that needs an external tool diff --git a/Compression.Tests/Erofs/ErofsExternalConformanceTests.cs b/Compression.Tests/Erofs/ErofsExternalConformanceTests.cs index 83784af36..18875bd87 100644 --- a/Compression.Tests/Erofs/ErofsExternalConformanceTests.cs +++ b/Compression.Tests/Erofs/ErofsExternalConformanceTests.cs @@ -77,7 +77,7 @@ private static bool TryFromPath(string tool, out string fullPath) { private static bool WslAvailable => _wslAvailable.Value; - private static readonly Dictionary _wslToolCache = new(StringComparer.Ordinal); + private static readonly System.Collections.Concurrent.ConcurrentDictionary _wslToolCache = new(StringComparer.Ordinal); // fixtures run in parallel private static bool WslHasTool(string tool) { if (!WslAvailable) return false; diff --git a/Compression.Tests/ExternalFsInteropTests.cs b/Compression.Tests/ExternalFsInteropTests.cs index 772c3b401..975c71c44 100644 --- a/Compression.Tests/ExternalFsInteropTests.cs +++ b/Compression.Tests/ExternalFsInteropTests.cs @@ -2554,7 +2554,7 @@ internal static class FsInteropToolbox { public static bool WslHasUfsKernelModule { get; } = DetectWslUfsModule(); public static bool WslHasAdfsKernelModule { get; } = DetectWslAdfsModule(); - private static readonly Dictionary _wslToolCache = new(StringComparer.Ordinal); + private static readonly System.Collections.Concurrent.ConcurrentDictionary _wslToolCache = new(StringComparer.Ordinal); // fixtures run in parallel private static bool DetectWslPasswordlessSudo() { if (!WslAvailable) return false; diff --git a/Compression.Tests/Fat/FatExternalConformanceTests.cs b/Compression.Tests/Fat/FatExternalConformanceTests.cs index f0b8ea8ed..b45d28c2b 100644 --- a/Compression.Tests/Fat/FatExternalConformanceTests.cs +++ b/Compression.Tests/Fat/FatExternalConformanceTests.cs @@ -89,7 +89,7 @@ private static bool TryFromPath(string tool, out string fullPath) { private static bool WslAvailable => _wslAvailable.Value; - private static readonly Dictionary _wslToolCache = new(StringComparer.Ordinal); + private static readonly System.Collections.Concurrent.ConcurrentDictionary _wslToolCache = new(StringComparer.Ordinal); // fixtures run in parallel private static bool WslHasTool(string tool) { if (!WslAvailable) return false; diff --git a/Compression.Tests/FatPlus/FatPlusExternalConformanceTests.cs b/Compression.Tests/FatPlus/FatPlusExternalConformanceTests.cs index 8877663c5..1ebaab3f8 100644 --- a/Compression.Tests/FatPlus/FatPlusExternalConformanceTests.cs +++ b/Compression.Tests/FatPlus/FatPlusExternalConformanceTests.cs @@ -70,7 +70,7 @@ private static bool TryFromPath(string tool, out string fullPath) { }); private static bool WslAvailable => _wslAvailable.Value; - private static readonly Dictionary _wslToolCache = new(StringComparer.Ordinal); + private static readonly System.Collections.Concurrent.ConcurrentDictionary _wslToolCache = new(StringComparer.Ordinal); // fixtures run in parallel private static bool WslHasTool(string tool) { if (!WslAvailable) return false; diff --git a/Compression.Tests/Lib/InMemoryProcessingTests.cs b/Compression.Tests/Lib/InMemoryProcessingTests.cs index 414a21ff7..550003c78 100644 --- a/Compression.Tests/Lib/InMemoryProcessingTests.cs +++ b/Compression.Tests/Lib/InMemoryProcessingTests.cs @@ -11,6 +11,7 @@ namespace Compression.Tests.Lib; /// directory. Write-back is atomic (temp-file + rename). /// [TestFixture] +[NonParallelizable] // sets the process-wide InMemoryProcessing.ThresholdBytes public class InMemoryProcessingTests { [Test, Category("Spec")] diff --git a/Compression.Tests/Operations/ConvertArchiveInMemoryTests.cs b/Compression.Tests/Operations/ConvertArchiveInMemoryTests.cs index 7fa96698c..ac979eb44 100644 --- a/Compression.Tests/Operations/ConvertArchiveInMemoryTests.cs +++ b/Compression.Tests/Operations/ConvertArchiveInMemoryTests.cs @@ -12,6 +12,7 @@ namespace Compression.Tests.Operations; /// and we exercise it by lowering the threshold. /// [TestFixture] +[NonParallelizable] // sets the process-wide InMemoryProcessing.ThresholdBytes and counts every cwb_x2m_/cwb_fsconv_ temp directory public class ConvertArchiveInMemoryTests { // ── Helpers ──────────────────────────────────────────────────────── diff --git a/Compression.Tests/Reiser4/Reiser4KernelOracle.cs b/Compression.Tests/Reiser4/Reiser4KernelOracle.cs index d338e2097..e7e4f65d0 100644 --- a/Compression.Tests/Reiser4/Reiser4KernelOracle.cs +++ b/Compression.Tests/Reiser4/Reiser4KernelOracle.cs @@ -12,13 +12,13 @@ namespace Compression.Tests.Reiser4; internal static class Reiser4KernelOracle { private static readonly string ScriptDirectory = Path.Combine(AppContext.BaseDirectory, "Reiser4", "KernelOracle"); - private static bool? _available; + // Lazy: fixtures run in parallel and Prepare() may download the kernel; it must run once. + private static readonly Lazy _available = new(Prepare); internal static void Require() { if (!FsInteropToolbox.WslAvailable && OperatingSystem.IsWindows()) Assert.Ignore("WSL is not available on this machine."); - _available ??= Prepare(); - if (_available != true) + if (!_available.Value) Assert.Ignore("The Reiser4 kernel oracle is not prepared (set CWB_REISER4_KERNEL_DOWNLOAD=1 to fetch it; needs qemu-system-x86_64)."); } diff --git a/Compression.Tests/Support/DmsdosCache.cs b/Compression.Tests/Support/DmsdosCache.cs index 55e8f5e1c..06ca65daf 100644 --- a/Compression.Tests/Support/DmsdosCache.cs +++ b/Compression.Tests/Support/DmsdosCache.cs @@ -37,11 +37,18 @@ private static string CacheDir { public static string CvfTest(string buildDir) => Path.Combine(buildDir, "cvftest"); public static string DcRead(string buildDir) => Path.Combine(buildDir, "dcread"); + private static readonly object FillGate = new(); + /// /// Returns the path to a built dmsdos build/ directory, or null /// if the tools cannot be produced on this machine. /// public static string? EnsureTools() { + // Parallel fixtures share this cache: one fill at a time, the rest then hit the cache. + lock (FillGate) return EnsureToolsUnlocked(); + } + + private static string? EnsureToolsUnlocked() { var explicitDir = Environment.GetEnvironmentVariable("CWB_DMSDOS_BUILD"); if (!string.IsNullOrEmpty(explicitDir) && File.Exists(Path.Combine(explicitDir, "cvftest"))) return explicitDir; diff --git a/Compression.Tests/Support/FreeDosCache.cs b/Compression.Tests/Support/FreeDosCache.cs index eb814e1fd..689e4133d 100644 --- a/Compression.Tests/Support/FreeDosCache.cs +++ b/Compression.Tests/Support/FreeDosCache.cs @@ -54,6 +54,8 @@ private static string CacheDir { } } + private static readonly object FillGate = new(); + /// /// Returns the cached path to FD14LIVE.iso, fetching + extracting on /// first use. Returns null on any failure — callers must @@ -64,6 +66,11 @@ private static string CacheDir { /// /// public static string? EnsureLiveCdIso() { + // Parallel fixtures share this cache: one fill at a time, the rest then hit the cache. + lock (FillGate) return EnsureLiveCdIsoUnlocked(); + } + + private static string? EnsureLiveCdIsoUnlocked() { // 1. Honor explicit env var first — air-gapped CI / dev machines can stage // the ISO out-of-band and skip the network round-trip. var explicitPath = Environment.GetEnvironmentVariable("CWB_FREEDOS_ISO"); @@ -81,7 +88,9 @@ private static string CacheDir { var bytes = _http.Value.GetByteArrayAsync(LiveCdZipUrl).GetAwaiter().GetResult(); if (!Sha256OfBytes(bytes).Equals(LiveCdZipSha256, StringComparison.OrdinalIgnoreCase)) return null; // Tampered mirror — refuse to cache a mystery blob. - File.WriteAllBytes(zipPath, bytes); + var partial = zipPath + "." + Guid.NewGuid().ToString("N") + ".partial"; + File.WriteAllBytes(partial, bytes); + File.Move(partial, zipPath, overwrite: true); } catch (HttpRequestException) { return null; // offline / DNS / 4xx / 5xx } catch (TaskCanceledException) { diff --git a/Compression.Tests/Support/RetroImageCache.cs b/Compression.Tests/Support/RetroImageCache.cs index fa9e4fe49..8414768e7 100644 --- a/Compression.Tests/Support/RetroImageCache.cs +++ b/Compression.Tests/Support/RetroImageCache.cs @@ -43,6 +43,8 @@ private static string CacheDir { public static string? Fetch(string url, string sha256, string localName) => FetchInternal(url, sha256, localName, decompressGz: false); + private static readonly object FillGate = new(); + /// /// Fetches a gzipped disk image (.d64.gz etc.), decompresses it, verifies /// the decompressed bytes' sha256 matches , @@ -53,6 +55,11 @@ private static string CacheDir { FetchInternal(url, sha256OfDecompressed, localName, decompressGz: true); private static string? FetchInternal(string url, string expectedSha256, string localName, bool decompressGz) { + // Parallel fixtures share this cache: one fill at a time, the rest then hit the cache. + lock (FillGate) return FetchInternalUnlocked(url, expectedSha256, localName, decompressGz); + } + + private static string? FetchInternalUnlocked(string url, string expectedSha256, string localName, bool decompressGz) { var localPath = Path.Combine(CacheDir, localName); // Cache hit: trust if sha matches. if (File.Exists(localPath) && Sha256Of(localPath).Equals(expectedSha256, StringComparison.OrdinalIgnoreCase)) @@ -73,7 +80,9 @@ private static string CacheDir { // Hash mismatch — don't cache a mystery blob; surface as unavailable. return null; } - File.WriteAllBytes(localPath, bytes); + var partial = localPath + "." + Guid.NewGuid().ToString("N") + ".partial"; + File.WriteAllBytes(partial, bytes); + File.Move(partial, localPath, overwrite: true); return localPath; } catch (HttpRequestException) { return null; // offline, DNS, 4xx, 5xx diff --git a/Compression.Tests/TestParallelism.cs b/Compression.Tests/TestParallelism.cs new file mode 100644 index 000000000..a7735e021 --- /dev/null +++ b/Compression.Tests/TestParallelism.cs @@ -0,0 +1,6 @@ +// Fixtures run in parallel; tests inside one fixture stay sequential, so fixture-level state +// (fields set up in [SetUp]/[OneTimeSetUp]) needs no locking. A fixture that touches anything +// process-wide - the current directory, environment variables, a global observer, a fixed +// temp path, sudo/device-mapper names - must either make that state its own or be marked +// [NonParallelizable] with the reason beside the attribute. +[assembly: Parallelizable(ParallelScope.Fixtures)]