From 09795d313528a30fee25f0081ec8168151fb7781 Mon Sep 17 00:00:00 2001 From: Hawkynt Date: Tue, 10 Nov 2026 22:13:35 +0100 Subject: [PATCH 1/3] * Keep test fixtures independent of each other Preparation for running fixtures in parallel; each change removes state two fixtures could share. - InMemoryProcessingTests and ConvertArchiveInMemoryTests set the process-wide InMemoryProcessing.ThresholdBytes (every conversion reads it), and the latter also counts every cwb_x2m_/cwb_fsconv_ temp directory: marked [NonParallelizable] with that reason. - RetroImageCache, FreeDosCache, DmsdosCache: fixtures share one download/ build cache directory; filling it is now serialised per cache (the others then hit the cache), and downloads land in a unique .partial file that is renamed into place, so no reader ever sees a half-written file. Audited and left alone because they are already per-fixture: Guid-named temp directories, device-mapper names and mount points; the NetBSD guest boots its shared live image with snapshot=on and gets its own data disk per run. --- Compression.Tests/Lib/InMemoryProcessingTests.cs | 1 + .../Operations/ConvertArchiveInMemoryTests.cs | 1 + Compression.Tests/Support/DmsdosCache.cs | 7 +++++++ Compression.Tests/Support/FreeDosCache.cs | 11 ++++++++++- Compression.Tests/Support/RetroImageCache.cs | 11 ++++++++++- 5 files changed, 29 insertions(+), 2 deletions(-) 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/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 From cd3ba00ba8b31d57efb1a200380504e9875f604c Mon Sep 17 00:00:00 2001 From: Hawkynt Date: Tue, 10 Nov 2026 22:13:35 +0100 Subject: [PATCH 2/3] * Run test fixtures in parallel Symptom (#812): the ubuntu-latest CI job took 74-90 minutes and PR #811's run was cancelled at its 90-minute timeout with every step green; the core tier alone took 18 min locally and 18-29 min on CI. Root cause: all ~42,900 tests ran strictly one after another (no NUnit parallelism), so wall-clock time was the sum of every test. Fix: [assembly: Parallelizable(ParallelScope.Fixtures)] - fixtures run in parallel, tests inside one fixture keep their order (fixture fields need no locking). The performance step stays sequential (NumberOfTestWorkers=1) so throughput measurements do not skew each other. CONTRIBUTING states the independence rules. Verified: the required tier ran three times in parallel on 16 cores - 7 m, 5 m 17 s, 5 m 11 s instead of 18 min - with identical results each time: 41,011 passed, and only the 3 tests that also fail sequentially on this host (local lrelease version, no wsl binary, PyInstaller toolchain). Closes #812 --- .github/workflows/ci.yml | 3 +++ CONTRIBUTING.md | 8 ++++++++ Compression.Tests/TestParallelism.cs | 6 ++++++ 3 files changed, 17 insertions(+) create mode 100644 Compression.Tests/TestParallelism.cs 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/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)] From 7f0594c2e279364648c5ba321b2988925f4aebe8 Mon Sep 17 00:00:00 2001 From: Hawkynt Date: Tue, 10 Nov 2026 23:21:51 +0100 Subject: [PATCH 3/3] # Make the shared tool probes safe for parallel fixtures Symptom: on PR #813's CI run the bcachefs oracle failed with "Operations that change non-concurrent collections must have exclusive access" in FsInteropToolbox.WslHasTool - two fixtures probing tools at the same time corrupted the cache. Root cause: tool-availability caches shared by many fixtures were plain Dictionary instances (FsInteropToolbox and the Erofs, Fat and FatPlus conformance helpers), and Reiser4KernelOracle's one-time preparation - which may download a kernel - could run twice at once. Fix: the four tool caches are ConcurrentDictionary; Reiser4KernelOracle's preparation is a Lazy that runs once. Swept the rest: the other static probes are private to one fixture (whose tests run in order) or compute an idempotent bool; the headless-UI recorders are used only by fixtures already marked [NonParallelizable]. Verified: required tier in parallel again - 41,011 passed, same 3 local toolchain failures, 5 m 11 s. --- Compression.Tests/Erofs/ErofsExternalConformanceTests.cs | 2 +- Compression.Tests/ExternalFsInteropTests.cs | 2 +- Compression.Tests/Fat/FatExternalConformanceTests.cs | 2 +- .../FatPlus/FatPlusExternalConformanceTests.cs | 2 +- Compression.Tests/Reiser4/Reiser4KernelOracle.cs | 6 +++--- 5 files changed, 7 insertions(+), 7 deletions(-) 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/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)."); }