From 877e806bba7eb1ceaccfa51f0d9d2a786d5e56bd Mon Sep 17 00:00:00 2001 From: JusterZhu Date: Tue, 26 May 2026 20:38:37 +0800 Subject: [PATCH 1/3] fix: make BsdiffDiffer thread-safe for concurrent pipeline use Remove per-call mutable instance fields (_oldfilePath, _newfilePath, _patchPath) that caused race conditions when a single BsdiffDiffer instance was shared across parallel DiffPipeline tasks. The Task.Run lambdas now capture parameters directly via closure, making each invocation independently thread-safe. Also add missing GeneralUpdate.Core project reference to DifferentialTest, and add comprehensive single/multi-file differential integration tests. Co-Authored-By: Claude Opus 4.7 --- .../DifferentialTest/DifferentialTest.csproj | 1 + .../Differ/BsdiffDiffer.cs | 40 +- .../ComprehensiveDifferentialTests.cs | 848 ++++++++++++++++++ 3 files changed, 864 insertions(+), 25 deletions(-) create mode 100644 tests/DifferentialTest/ComprehensiveDifferentialTests.cs diff --git a/src/c#/DifferentialTest/DifferentialTest.csproj b/src/c#/DifferentialTest/DifferentialTest.csproj index 4895faf5..1011e8a9 100644 --- a/src/c#/DifferentialTest/DifferentialTest.csproj +++ b/src/c#/DifferentialTest/DifferentialTest.csproj @@ -11,6 +11,7 @@ + diff --git a/src/c#/GeneralUpdate.Differential/Differ/BsdiffDiffer.cs b/src/c#/GeneralUpdate.Differential/Differ/BsdiffDiffer.cs index 1c1b29c1..9d0ac61f 100644 --- a/src/c#/GeneralUpdate.Differential/Differ/BsdiffDiffer.cs +++ b/src/c#/GeneralUpdate.Differential/Differ/BsdiffDiffer.cs @@ -15,9 +15,8 @@ namespace GeneralUpdate.Differential.Differ /// Default: (backward compatible). /// Use (0x01) for faster decompression. /// - /// Thread-safety: this class stores per-call state in instance fields. - /// A single instance MUST NOT be used concurrently by multiple callers. - /// Create separate instances for concurrent operations. + /// Thread-safety: this class is stateless beyond the compression provider. + /// A single instance is safe for concurrent calls. /// public class BsdiffDiffer : IBinaryDiffer { @@ -31,8 +30,6 @@ public class BsdiffDiffer : IBinaryDiffer private const int ExtendedHeaderSize = 33; private readonly ICompressionProvider _compressionProvider; - // Per-call mutable state. NOT thread-safe for concurrent calls on the same instance. - private string _oldfilePath, _newfilePath, _patchPath; #endregion Private Members @@ -84,15 +81,12 @@ public async Task Clean(string oldfilePath, string newfilePath, string patchPath { await Task.Run(() => { - _oldfilePath = oldfilePath; - _newfilePath = newfilePath; - _patchPath = patchPath; - ValidationParameters(); + ValidationParameters(oldfilePath, newfilePath, patchPath); using (var output = new FileStream(patchPath, FileMode.Create)) { - var oldBytes = File.ReadAllBytes(_oldfilePath); - var newBytes = File.ReadAllBytes(_newfilePath); + var oldBytes = File.ReadAllBytes(oldfilePath); + var newBytes = File.ReadAllBytes(newfilePath); // Header layout: // 0 8 "BSDIFF40" (magic) @@ -270,13 +264,10 @@ public async Task Dirty(string oldfilePath, string newfilePath, string patchPath { await Task.Run(() => { - _oldfilePath = oldfilePath; - _newfilePath = newfilePath; - _patchPath = patchPath; - ValidationParameters(); + ValidationParameters(oldfilePath, newfilePath, patchPath); - using (var input = new FileStream(_oldfilePath, FileMode.Open, FileAccess.Read, FileShare.Read)) - using (var output = new FileStream(_newfilePath, FileMode.Create)) + using (var input = new FileStream(oldfilePath, FileMode.Open, FileAccess.Read, FileShare.Read)) + using (var output = new FileStream(newfilePath, FileMode.Create)) { long controlLength, diffLength, newSize; byte formatVersion; @@ -432,7 +423,6 @@ await Task.Run(() => } } - // The result is left at _newfilePath. // Atomic replacement (if needed) is handled by the caller // (e.g. DefaultDirtyStrategy.ApplyPatch). }); @@ -450,14 +440,14 @@ private static FileStream OpenPatchStream(string patchPath) return new FileStream(patchPath, FileMode.Open, FileAccess.Read, FileShare.Read); } - private void ValidationParameters() + private static void ValidationParameters(string oldfilePath, string newfilePath, string patchPath) { - if (string.IsNullOrWhiteSpace(_oldfilePath)) - throw new ArgumentNullException(nameof(_oldfilePath), "Old file path must not be empty."); - if (string.IsNullOrWhiteSpace(_newfilePath)) - throw new ArgumentNullException(nameof(_newfilePath), "New file path must not be empty."); - if (string.IsNullOrWhiteSpace(_patchPath)) - throw new ArgumentNullException(nameof(_patchPath), "Patch path must not be empty."); + if (string.IsNullOrWhiteSpace(oldfilePath)) + throw new ArgumentNullException(nameof(oldfilePath), "Old file path must not be empty."); + if (string.IsNullOrWhiteSpace(newfilePath)) + throw new ArgumentNullException(nameof(newfilePath), "New file path must not be empty."); + if (string.IsNullOrWhiteSpace(patchPath)) + throw new ArgumentNullException(nameof(patchPath), "Patch path must not be empty."); } private static int CompareBytes(byte[] left, int leftOffset, byte[] right, int rightOffset) diff --git a/tests/DifferentialTest/ComprehensiveDifferentialTests.cs b/tests/DifferentialTest/ComprehensiveDifferentialTests.cs new file mode 100644 index 00000000..891823dc --- /dev/null +++ b/tests/DifferentialTest/ComprehensiveDifferentialTests.cs @@ -0,0 +1,848 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GeneralUpdate.Core.Models; +using GeneralUpdate.Core.Pipeline; +using GeneralUpdate.Differential.Abstractions; +using GeneralUpdate.Differential.Differ; +using Xunit; +using Xunit.Abstractions; + +namespace DifferentialTest; + +/// +/// Comprehensive tests covering single-file and multi-file parallel differential scenarios. +/// +public class ComprehensiveDifferentialTests : IDisposable +{ + private readonly string _testDir; + private readonly ITestOutputHelper _output; + + public ComprehensiveDifferentialTests(ITestOutputHelper output) + { + _testDir = Path.Combine(Path.GetTempPath(), $"CompDiff_{Guid.NewGuid():N}"); + _output = output; + Directory.CreateDirectory(_testDir); + } + + public void Dispose() + { + try { Directory.Delete(_testDir, true); } catch { /* best-effort */ } + } + + // ========================================================================= + // Section 1: Single-file differential -- BsdiffDiffer + // ========================================================================= + + [Fact] + public async Task BsdiffDiffer_CleanThenDirty_TextFile_RoundTrip() + { + var oldFile = Path.Combine(_testDir, "old.txt"); + var newFile = Path.Combine(_testDir, "new.txt"); + var patchFile = Path.Combine(_testDir, "patch.bin"); + var restoredFile = Path.Combine(_testDir, "restored.txt"); + + var oldContent = "v1.0.0 application data\nline 2\nline 3\nline 4\nline 5"; + var newContent = "v2.0.0 application data\nline 2 modified\nline 3\nline 4 enhanced\nline 5"; + + File.WriteAllText(oldFile, oldContent); + File.WriteAllText(newFile, newContent); + + var differ = new BsdiffDiffer(); + + // Act: generate patch + await differ.CleanAsync(oldFile, newFile, patchFile); + Assert.True(File.Exists(patchFile)); + + // Act: apply patch + await differ.DirtyAsync(oldFile, restoredFile, patchFile); + + // Assert: restored = new + Assert.True(File.Exists(restoredFile)); + Assert.Equal(newContent, File.ReadAllText(restoredFile)); + } + + [Fact] + public async Task BsdiffDiffer_BinaryFile_RoundTrip() + { + var oldFile = Path.Combine(_testDir, "old.bin"); + var newFile = Path.Combine(_testDir, "new.bin"); + var patchFile = Path.Combine(_testDir, "patch.bin"); + var restoredFile = Path.Combine(_testDir, "restored.bin"); + + var rng = new Random(42); + var oldData = new byte[256 * 1024]; // 256KB + rng.NextBytes(oldData); + + var newData = (byte[])oldData.Clone(); + // Modify several blocks + for (var i = 0; i < 10; i++) + { + var offset = rng.Next(newData.Length - 16); + var len = rng.Next(1, 16); + for (var j = 0; j < len; j++) + newData[offset + j] = (byte)rng.Next(256); + } + // Insert some data + var insert = new byte[1024]; + rng.NextBytes(insert); + var mid = newData.Length / 2; + newData = [.. newData[..mid], .. insert, .. newData[mid..]]; + + File.WriteAllBytes(oldFile, oldData); + File.WriteAllBytes(newFile, newData); + + var differ = new BsdiffDiffer(); + + await differ.CleanAsync(oldFile, newFile, patchFile); + Assert.True(File.Exists(patchFile)); + + await differ.DirtyAsync(oldFile, restoredFile, patchFile); + Assert.True(File.Exists(restoredFile)); + Assert.Equal(newData, File.ReadAllBytes(restoredFile)); + } + + [Fact] + public async Task BsdiffDiffer_EmptyFile_RoundTrip() + { + var emptyFile = Path.Combine(_testDir, "empty.bin"); + var nonEmptyFile = Path.Combine(_testDir, "nonempty.bin"); + var patchFile = Path.Combine(_testDir, "patch.bin"); + var restoredFile = Path.Combine(_testDir, "restored.bin"); + + File.WriteAllBytes(emptyFile, []); + File.WriteAllBytes(nonEmptyFile, [1, 2, 3, 4, 5]); + + var differ = new BsdiffDiffer(); + + // Empty -> Non-empty + await differ.CleanAsync(emptyFile, nonEmptyFile, patchFile); + await differ.DirtyAsync(emptyFile, restoredFile, patchFile); + Assert.Equal([1, 2, 3, 4, 5], File.ReadAllBytes(restoredFile)); + + // Non-empty -> Empty + var patchFile2 = Path.Combine(_testDir, "patch2.bin"); + var restoredFile2 = Path.Combine(_testDir, "restored2.bin"); + await differ.CleanAsync(nonEmptyFile, emptyFile, patchFile2); + await differ.DirtyAsync(nonEmptyFile, restoredFile2, patchFile2); + Assert.Equal([], File.ReadAllBytes(restoredFile2)); + } + + [Fact] + public async Task BsdiffDiffer_IdenticalFiles_ProducesMinimalPatch() + { + var oldFile = Path.Combine(_testDir, "old.bin"); + var patchFile = Path.Combine(_testDir, "patch.bin"); + var restoredFile = Path.Combine(_testDir, "restored.bin"); + + var data = new byte[4096]; + new Random(42).NextBytes(data); + File.WriteAllBytes(oldFile, data); + + var differ = new BsdiffDiffer(); + + await differ.CleanAsync(oldFile, oldFile, patchFile); + Assert.True(File.Exists(patchFile)); + + await differ.DirtyAsync(oldFile, restoredFile, patchFile); + Assert.Equal(data, File.ReadAllBytes(restoredFile)); + } + + [Fact] + public async Task BsdiffDiffer_WithDeflateCompression_RoundTrip() + { + var oldFile = Path.Combine(_testDir, "old.txt"); + var newFile = Path.Combine(_testDir, "new.txt"); + var patchFile = Path.Combine(_testDir, "patch.bin"); + var restoredFile = Path.Combine(_testDir, "restored.txt"); + + var oldContent = "ABCDEFGH" + new string('X', 10000) + "IJKLMNOP"; + var newContent = "ABCDEFGH" + new string('Y', 5000) + "ZZZ" + new string('X', 5000) + "IJKLMNOP"; + + File.WriteAllText(oldFile, oldContent); + File.WriteAllText(newFile, newContent); + + var differ = new BsdiffDiffer(new DeflateCompressionProvider()); + + await differ.CleanAsync(oldFile, newFile, patchFile); + await differ.DirtyAsync(oldFile, restoredFile, patchFile); + + Assert.Equal(newContent, File.ReadAllText(restoredFile)); + } + + [Fact] + public async Task BsdiffDiffer_LargeFile_RoundTrip() + { + var oldFile = Path.Combine(_testDir, "old.bin"); + var newFile = Path.Combine(_testDir, "new.bin"); + var patchFile = Path.Combine(_testDir, "patch.bin"); + var restoredFile = Path.Combine(_testDir, "restored.bin"); + + // ~1MB file + var oldData = new byte[1024 * 1024]; + new Random(123).NextBytes(oldData); + + var newData = (byte[])oldData.Clone(); + // Modify the middle section + for (var i = 512 * 1024; i < 512 * 1024 + 8192; i++) + newData[i] = (byte)(oldData[i] ^ 0xFF); + + File.WriteAllBytes(oldFile, oldData); + File.WriteAllBytes(newFile, newData); + + var differ = new BsdiffDiffer(); + var sw = Stopwatch.StartNew(); + + await differ.CleanAsync(oldFile, newFile, patchFile); + _output.WriteLine($"Bsdiff Clean (1MB): {sw.ElapsedMilliseconds}ms, patch={new FileInfo(patchFile).Length} bytes"); + + sw.Restart(); + await differ.DirtyAsync(oldFile, restoredFile, patchFile); + _output.WriteLine($"Bsdiff Dirty (1MB): {sw.ElapsedMilliseconds}ms"); + + Assert.Equal(newData, File.ReadAllBytes(restoredFile)); + } + + // ========================================================================= + // Section 2: Single-file differential -- StreamingHdiffDiffer + // ========================================================================= + + [Fact] + public async Task StreamingHdiffDiffer_TextFile_RoundTrip() + { + var oldFile = Path.Combine(_testDir, "old.txt"); + var newFile = Path.Combine(_testDir, "new.txt"); + var patchFile = Path.Combine(_testDir, "patch.bin"); + var restoredFile = Path.Combine(_testDir, "restored.txt"); + + var oldContent = "Original program v1\nstatic config\nadmin panel off\n"; + var newContent = "Original program v2\nstatic config updated\nadmin panel on\ndebug mode on\n"; + + File.WriteAllText(oldFile, oldContent); + File.WriteAllText(newFile, newContent); + + var differ = new StreamingHdiffDiffer(); + + await differ.CleanAsync(oldFile, newFile, patchFile); + Assert.True(File.Exists(patchFile)); + + await differ.DirtyAsync(oldFile, restoredFile, patchFile); + Assert.Equal(newContent, File.ReadAllText(restoredFile)); + } + + [Fact] + public async Task StreamingHdiffDiffer_BinaryFile_RoundTrip() + { + var oldFile = Path.Combine(_testDir, "old.bin"); + var newFile = Path.Combine(_testDir, "new.bin"); + var patchFile = Path.Combine(_testDir, "patch.bin"); + var restoredFile = Path.Combine(_testDir, "restored.bin"); + + var rng = new Random(99); + var oldData = new byte[128 * 1024]; + rng.NextBytes(oldData); + + var newData = (byte[])oldData.Clone(); + // Scattered small modifications + for (var i = 0; i < 50; i++) + { + var off = rng.Next(newData.Length - 1); + newData[off] = (byte)rng.Next(256); + } + + File.WriteAllBytes(oldFile, oldData); + File.WriteAllBytes(newFile, newData); + + var differ = new StreamingHdiffDiffer(); + + await differ.CleanAsync(oldFile, newFile, patchFile); + await differ.DirtyAsync(oldFile, restoredFile, patchFile); + Assert.Equal(newData, File.ReadAllBytes(restoredFile)); + } + + [Fact] + public async Task StreamingHdiffDiffer_EmptyFile_RoundTrip() + { + var emptyFile = Path.Combine(_testDir, "empty.bin"); + var nonEmptyFile = Path.Combine(_testDir, "nonempty.bin"); + var patchFile = Path.Combine(_testDir, "patch.bin"); + + File.WriteAllBytes(emptyFile, []); + File.WriteAllBytes(nonEmptyFile, new byte[2048]); + + var differ = new StreamingHdiffDiffer(); + + // Empty -> Non-empty + await differ.CleanAsync(emptyFile, nonEmptyFile, patchFile); + var restored = Path.Combine(_testDir, "restored1.bin"); + await differ.DirtyAsync(emptyFile, restored, patchFile); + Assert.Equal(2048, new FileInfo(restored).Length); + + // Non-empty -> Empty + var patchFile2 = Path.Combine(_testDir, "patch2.bin"); + await differ.CleanAsync(nonEmptyFile, emptyFile, patchFile2); + var restored2 = Path.Combine(_testDir, "restored2.bin"); + await differ.DirtyAsync(nonEmptyFile, restored2, patchFile2); + Assert.Equal(0, new FileInfo(restored2).Length); + } + + [Fact] + public async Task StreamingHdiffDiffer_IdenticalFiles_MinimalPatch() + { + var file = Path.Combine(_testDir, "file.bin"); + var patchFile = Path.Combine(_testDir, "patch.bin"); + + var data = new byte[8192]; + new Random(7).NextBytes(data); + File.WriteAllBytes(file, data); + + var differ = new StreamingHdiffDiffer(); + await differ.CleanAsync(file, file, patchFile); + + var restored = Path.Combine(_testDir, "restored.bin"); + await differ.DirtyAsync(file, restored, patchFile); + Assert.Equal(data, File.ReadAllBytes(restored)); + } + + [Fact] + public async Task StreamingHdiffDiffer_WithBZip2_RoundTrip() + { + var oldFile = Path.Combine(_testDir, "old.txt"); + var newFile = Path.Combine(_testDir, "new.txt"); + var patchFile = Path.Combine(_testDir, "patch.bin"); + var restoredFile = Path.Combine(_testDir, "restored.txt"); + + File.WriteAllText(oldFile, "AAAA" + new string('B', 5000) + "CCCC"); + File.WriteAllText(newFile, "AAAA" + new string('D', 3000) + "CCCC2222"); + + var differ = new StreamingHdiffDiffer(new BZip2CompressionProvider(), 64 * 1024, 128 * 1024 * 1024); + + await differ.CleanAsync(oldFile, newFile, patchFile); + await differ.DirtyAsync(oldFile, restoredFile, patchFile); + Assert.Equal("AAAA" + new string('D', 3000) + "CCCC2222", File.ReadAllText(restoredFile)); + } + + [Fact] + public async Task StreamingHdiffDiffer_LargeFile_RoundTrip() + { + var oldFile = Path.Combine(_testDir, "old.bin"); + var newFile = Path.Combine(_testDir, "new.bin"); + var patchFile = Path.Combine(_testDir, "patch.bin"); + var restoredFile = Path.Combine(_testDir, "restored.bin"); + + // ~2MB + var oldData = new byte[2 * 1024 * 1024]; + new Random(456).NextBytes(oldData); + + var newData = (byte[])oldData.Clone(); + // Modify three regions + for (var i = 0; i < 4096; i++) + newData[1024 * 256 + i] = (byte)(oldData[1024 * 256 + i] ^ 0xAA); + for (var i = 0; i < 2048; i++) + newData[1024 * 1024 + i] = 0x42; + + File.WriteAllBytes(oldFile, oldData); + File.WriteAllBytes(newFile, newData); + + var differ = new StreamingHdiffDiffer(); + var sw = Stopwatch.StartNew(); + + await differ.CleanAsync(oldFile, newFile, patchFile); + _output.WriteLine($"StreamingHdiff Clean (2MB): {sw.ElapsedMilliseconds}ms, patch={new FileInfo(patchFile).Length} bytes"); + + sw.Restart(); + await differ.DirtyAsync(oldFile, restoredFile, patchFile); + _output.WriteLine($"StreamingHdiff Dirty (2MB): {sw.ElapsedMilliseconds}ms"); + + Assert.Equal(newData, File.ReadAllBytes(restoredFile)); + } + + // ========================================================================= + // Section 3: Multi-file parallel differential -- DiffPipeline + // ========================================================================= + + [Fact] + public async Task DiffPipeline_ParallelClean_AllFilesGeneratePatches() + { + var src = Path.Combine(_testDir, "src"); + var tgt = Path.Combine(_testDir, "tgt"); + var patchDir = Path.Combine(_testDir, "patches"); + Directory.CreateDirectory(src); + Directory.CreateDirectory(tgt); + Directory.CreateDirectory(patchDir); + + // Create 30 files in source and target with modifications + for (var i = 0; i < 30; i++) + { + var subDir = i % 5 == 0 ? $"sub{i / 5}" : ""; + if (subDir.Length > 0) + { + Directory.CreateDirectory(Path.Combine(src, subDir)); + Directory.CreateDirectory(Path.Combine(tgt, subDir)); + Directory.CreateDirectory(Path.Combine(patchDir, subDir)); + } + + var fileName = Path.Combine(subDir, $"module_{i:D3}.dll"); + File.WriteAllBytes(Path.Combine(src, fileName), GenerateBytes(1024 + i * 64, i)); + File.WriteAllBytes(Path.Combine(tgt, fileName), GenerateBytes(1024 + i * 64, i + 100)); + } + + // Also add some new files in target (no old version) + File.WriteAllBytes(Path.Combine(tgt, "new_features.dll"), [1, 2, 3, 4, 5]); + + var completedFiles = new ConcurrentBag(); + var progress = new SyncProgress(); + var pipeline = new DiffPipelineBuilder() + .WithParallelism(Environment.ProcessorCount) + .Build(); + + // Act + var sw = Stopwatch.StartNew(); + await pipeline.CleanAsync(src, tgt, patchDir, progress); + _output.WriteLine($"Parallel Clean (30 files): {sw.ElapsedMilliseconds}ms"); + + // Assert + Assert.True(progress.LastValue.IsComplete); + _output.WriteLine($"Progress: {progress.LastValue.Completed}/{progress.LastValue.Total}"); + + // Each modified file should have a .patch + for (var i = 0; i < 30; i++) + { + var subDir = i % 5 == 0 ? $"sub{i / 5}" : ""; + var patchPath = Path.Combine(patchDir, subDir, $"module_{i:D3}.dll.patch"); + Assert.True(File.Exists(patchPath), $"Patch missing for module_{i:D3}.dll"); + } + + // New file should be copied directly + Assert.True(File.Exists(Path.Combine(patchDir, "new_features.dll"))); + } + + [Fact] + public async Task DiffPipeline_FullCycle_CleanThenDirty_ResultsMatchTarget() + { + var src = Path.Combine(_testDir, "src"); + var tgt = Path.Combine(_testDir, "tgt"); + var app = Path.Combine(_testDir, "app"); + var patchDir = Path.Combine(_testDir, "patches"); + Directory.CreateDirectory(src); + Directory.CreateDirectory(tgt); + Directory.CreateDirectory(app); + Directory.CreateDirectory(patchDir); + + // Setup: various file operations + var fileSpecs = new Dictionary + { + ["core.dll"] = (GenerateBytes(4096, 0), GenerateBytes(4096, 100)), + ["config.json"] = ( + System.Text.Encoding.UTF8.GetBytes("{\"version\":\"1.0\"}"), + System.Text.Encoding.UTF8.GetBytes("{\"version\":\"2.0\",\"debug\":true}")), + ["lib/utils.dll"] = (GenerateBytes(2048, 42), GenerateBytes(2048, 42)), // unchanged + ["assets/texture.bin"] = (GenerateBytes(8192, 7), GenerateBytes(8192, 77)), + }; + + // New files in target (no old version) + var newFileContent = GenerateBytes(512, 99); + + // Populate source and app (old versions) + foreach (var (path, (old, _)) in fileSpecs) + { + var srcPath = Path.Combine(src, path); + Directory.CreateDirectory(Path.GetDirectoryName(srcPath)!); + File.WriteAllBytes(srcPath, old); + + var appPath = Path.Combine(app, path); + Directory.CreateDirectory(Path.GetDirectoryName(appPath)!); + File.WriteAllBytes(appPath, old); + } + + // Populate target (new versions) + foreach (var (path, (_, @new)) in fileSpecs) + { + var tgtPath = Path.Combine(tgt, path); + Directory.CreateDirectory(Path.GetDirectoryName(tgtPath)!); + File.WriteAllBytes(tgtPath, @new); + } + // New file + File.WriteAllBytes(Path.Combine(tgt, "new_plugin.dll"), newFileContent); + + // Act: generate patches + var pipeline = new DiffPipelineBuilder().WithParallelism(4).Build(); + await pipeline.CleanAsync(src, tgt, patchDir); + + // Act: apply patches + await pipeline.DirtyAsync(app, patchDir); + + // Assert: app now matches target for all files + foreach (var (path, (_, expected)) in fileSpecs) + { + var resultPath = Path.Combine(app, path); + Assert.True(File.Exists(resultPath), $"File '{path}' should exist in app after Dirty"); + Assert.Equal(expected, File.ReadAllBytes(resultPath)); + } + + // Assert: new file should exist in app + Assert.True(File.Exists(Path.Combine(app, "new_plugin.dll"))); + Assert.Equal(newFileContent, File.ReadAllBytes(Path.Combine(app, "new_plugin.dll"))); + } + + [Fact] + public async Task DiffPipeline_Parallelism1_Serial_WorksCorrectly() + { + var src = Path.Combine(_testDir, "src"); + var tgt = Path.Combine(_testDir, "tgt"); + var patchDir = Path.Combine(_testDir, "patches"); + Directory.CreateDirectory(src); + Directory.CreateDirectory(tgt); + Directory.CreateDirectory(patchDir); + + for (var i = 0; i < 10; i++) + { + var dir = i % 3 == 0 ? $"layer{i / 3}" : ""; + if (dir.Length > 0) + { + Directory.CreateDirectory(Path.Combine(src, dir)); + Directory.CreateDirectory(Path.Combine(tgt, dir)); + } + File.WriteAllText(Path.Combine(src, dir, $"file_{i}.txt"), $"old version {i} content"); + File.WriteAllText(Path.Combine(tgt, dir, $"file_{i}.txt"), $"new version {i} updated content"); + } + + var pipeline = new DiffPipelineBuilder() + .WithParallelism(1) // serial + .Build(); + + await pipeline.CleanAsync(src, tgt, patchDir); + + for (var i = 0; i < 10; i++) + { + var dir = i % 3 == 0 ? $"layer{i / 3}" : ""; + Assert.True(File.Exists(Path.Combine(patchDir, dir, $"file_{i}.txt.patch"))); + } + } + + [Fact] + public async Task DiffPipeline_CustomDiffer_StreamingHdiffDiffer_Works() + { + var src = Path.Combine(_testDir, "src"); + var tgt = Path.Combine(_testDir, "tgt"); + var app = Path.Combine(_testDir, "app"); + var patchDir = Path.Combine(_testDir, "patches"); + Directory.CreateDirectory(src); + Directory.CreateDirectory(tgt); + Directory.CreateDirectory(app); + Directory.CreateDirectory(patchDir); + + for (var i = 0; i < 15; i++) + { + var oldContent = GenerateBytes(2048, i); + var newContent = GenerateBytes(2048, i + 1000); + File.WriteAllBytes(Path.Combine(src, $"comp_{i:D2}.dll"), oldContent); + File.WriteAllBytes(Path.Combine(tgt, $"comp_{i:D2}.dll"), newContent); + File.WriteAllBytes(Path.Combine(app, $"comp_{i:D2}.dll"), oldContent); + } + + // Use StreamingHdiffDiffer with BZip2 compression + var pipeline = new DiffPipelineBuilder() + .UseDiffer(new StreamingHdiffDiffer(new BZip2CompressionProvider(), 32 * 1024, 64 * 1024 * 1024)) + .WithParallelism(4) + .Build(); + + // Generate patches + await pipeline.CleanAsync(src, tgt, patchDir); + // Apply patches + await pipeline.DirtyAsync(app, patchDir); + + // Verify + for (var i = 0; i < 15; i++) + { + var expected = GenerateBytes(2048, i + 1000); + var actual = File.ReadAllBytes(Path.Combine(app, $"comp_{i:D2}.dll")); + Assert.Equal(expected, actual); + } + } + + [Fact] + public async Task DiffPipeline_CustomDiffer_BsdiffDiffer_Works() + { + var src = Path.Combine(_testDir, "src"); + var tgt = Path.Combine(_testDir, "tgt"); + var app = Path.Combine(_testDir, "app"); + var patchDir = Path.Combine(_testDir, "patches"); + Directory.CreateDirectory(src); + Directory.CreateDirectory(tgt); + Directory.CreateDirectory(app); + Directory.CreateDirectory(patchDir); + + for (var i = 0; i < 10; i++) + { + var oldContent = GenerateBytes(1024, i * 2); + var newContent = GenerateBytes(1024, i * 2 + 777); + File.WriteAllBytes(Path.Combine(src, $"bs_{i:D2}.bin"), oldContent); + File.WriteAllBytes(Path.Combine(tgt, $"bs_{i:D2}.bin"), newContent); + File.WriteAllBytes(Path.Combine(app, $"bs_{i:D2}.bin"), oldContent); + } + + var pipeline = new DiffPipelineBuilder() + .UseDiffer(new BsdiffDiffer(new DeflateCompressionProvider())) + .WithParallelism(4) + .Build(); + + await pipeline.CleanAsync(src, tgt, patchDir); + await pipeline.DirtyAsync(app, patchDir); + + for (var i = 0; i < 10; i++) + { + var expected = GenerateBytes(1024, i * 2 + 777); + var actual = File.ReadAllBytes(Path.Combine(app, $"bs_{i:D2}.bin")); + Assert.Equal(expected, actual); + } + } + + [Fact] + public async Task DiffPipeline_ProgressReporting_TracksAccurately() + { + var src = Path.Combine(_testDir, "src"); + var tgt = Path.Combine(_testDir, "tgt"); + var patchDir = Path.Combine(_testDir, "patches"); + Directory.CreateDirectory(src); + Directory.CreateDirectory(tgt); + Directory.CreateDirectory(patchDir); + + for (var i = 0; i < 25; i++) + { + File.WriteAllBytes(Path.Combine(src, $"f{i:D3}.dat"), GenerateBytes(512, i)); + File.WriteAllBytes(Path.Combine(tgt, $"f{i:D3}.dat"), GenerateBytes(512, i + 500)); + } + + var progressValues = new List(); + var progress = new Progress(p => progressValues.Add(p)); + + var pipeline = new DiffPipelineBuilder().WithParallelism(4).Build(); + await pipeline.CleanAsync(src, tgt, patchDir, progress); + + Assert.NotEmpty(progressValues); + Assert.Contains(progressValues, p => p.IsComplete); + Assert.True(progressValues.Any(p => p.Completed > 0)); + } + + [Fact] + public async Task DiffPipeline_DeleteHandling_RemovesDeletedFiles() + { + var src = Path.Combine(_testDir, "src"); + var tgt = Path.Combine(_testDir, "tgt"); + var app = Path.Combine(_testDir, "app"); + var patchDir = Path.Combine(_testDir, "patches"); + Directory.CreateDirectory(src); + Directory.CreateDirectory(tgt); + Directory.CreateDirectory(app); + Directory.CreateDirectory(patchDir); + + // File exists in source but NOT in target (deleted) + var deletedFileName = "obsolete.dll"; + File.WriteAllBytes(Path.Combine(src, deletedFileName), [1, 2, 3, 4, 5]); + File.WriteAllBytes(Path.Combine(app, deletedFileName), [1, 2, 3, 4, 5]); + + // File exists in both (modified) + File.WriteAllBytes(Path.Combine(src, "core.dll"), GenerateBytes(1024, 1)); + File.WriteAllBytes(Path.Combine(tgt, "core.dll"), GenerateBytes(1024, 100)); + File.WriteAllBytes(Path.Combine(app, "core.dll"), GenerateBytes(1024, 1)); + + var pipeline = new DiffPipelineBuilder().WithParallelism(2).Build(); + + // Generate patches (delete list + patch for core.dll) + await pipeline.CleanAsync(src, tgt, patchDir); + + // Verify delete list exists + Assert.True(File.Exists(Path.Combine(patchDir, "generalupdate_delete_files.json"))); + + // Apply patches + await pipeline.DirtyAsync(app, patchDir); + + // obsolete.dll should be deleted + Assert.False(File.Exists(Path.Combine(app, deletedFileName)), + $"Deleted file '{deletedFileName}' should be removed from app"); + + // core.dll should be updated + Assert.Equal(GenerateBytes(1024, 100), File.ReadAllBytes(Path.Combine(app, "core.dll"))); + } + + [Fact] + public async Task DiffPipeline_OnlyNewFiles_NoPatchesNeeded() + { + var src = Path.Combine(_testDir, "src"); + var tgt = Path.Combine(_testDir, "tgt"); + var app = Path.Combine(_testDir, "app"); + var patchDir = Path.Combine(_testDir, "patches"); + Directory.CreateDirectory(src); + Directory.CreateDirectory(tgt); + Directory.CreateDirectory(app); + Directory.CreateDirectory(patchDir); + + // Source is empty, target has new files + File.WriteAllBytes(Path.Combine(tgt, "brand_new_1.dll"), [10, 20, 30]); + File.WriteAllBytes(Path.Combine(tgt, "brand_new_2.dll"), [40, 50, 60]); + + var pipeline = new DiffPipelineBuilder().Build(); + + // Generate patches + await pipeline.CleanAsync(src, tgt, patchDir); + + // New files should be copied directly (no .patch generated) + Assert.True(File.Exists(Path.Combine(patchDir, "brand_new_1.dll"))); + Assert.True(File.Exists(Path.Combine(patchDir, "brand_new_2.dll"))); + + // Apply patches + await pipeline.DirtyAsync(app, patchDir); + + Assert.True(File.Exists(Path.Combine(app, "brand_new_1.dll"))); + Assert.Equal([10, 20, 30], File.ReadAllBytes(Path.Combine(app, "brand_new_1.dll"))); + } + + [Fact] + public async Task DiffPipeline_Cancellation_CleansUpGracefully() + { + var src = Path.Combine(_testDir, "src"); + var tgt = Path.Combine(_testDir, "tgt"); + var patchDir = Path.Combine(_testDir, "patches"); + Directory.CreateDirectory(src); + Directory.CreateDirectory(tgt); + Directory.CreateDirectory(patchDir); + + for (var i = 0; i < 20; i++) + { + File.WriteAllBytes(Path.Combine(src, $"c{i}.dat"), GenerateBytes(1024, i)); + File.WriteAllBytes(Path.Combine(tgt, $"c{i}.dat"), GenerateBytes(1024, i + 100)); + } + + var pipeline = new DiffPipelineBuilder().WithParallelism(2).Build(); + var cts = new CancellationTokenSource(); + cts.Cancel(); + + await Assert.ThrowsAnyAsync( + () => pipeline.CleanAsync(src, tgt, patchDir, cancellationToken: cts.Token)); + } + + [Fact] + public async Task DiffPipeline_DirtyAsync_NoExistingPatches_HandledGracefully() + { + var app = Path.Combine(_testDir, "app"); + var patchDir = Path.Combine(_testDir, "patches"); + Directory.CreateDirectory(app); + Directory.CreateDirectory(patchDir); + + File.WriteAllBytes(Path.Combine(app, "x.dll"), [1, 2, 3]); + + var pipeline = new DiffPipelineBuilder().Build(); + // Should not throw when there are no patch files to apply + await pipeline.DirtyAsync(app, patchDir); + + Assert.True(File.Exists(Path.Combine(app, "x.dll"))); + } + + // ========================================================================= + // Section 4: Edge cases and stress tests + // ========================================================================= + + [Fact] + public async Task SingleFile_LargeContent_20MB_DifferentialWorks() + { + var oldFile = Path.Combine(_testDir, "old.bin"); + var newFile = Path.Combine(_testDir, "new.bin"); + var patchFile = Path.Combine(_testDir, "patch.bin"); + var restoredFile = Path.Combine(_testDir, "restored.bin"); + + // ~5MB files (keep reasonable for test runtime) + var oldData = new byte[5 * 1024 * 1024]; + new Random(888).NextBytes(oldData); + + var newData = (byte[])oldData.Clone(); + // Modify ~10% scattered + for (var i = 0; i < 100; i++) + { + var off = (int)(((long)i * newData.Length) / 100); + for (var j = 0; j < 1024 && off + j < newData.Length; j++) + newData[off + j] = (byte)(oldData[off + j] ^ 0x55); + } + + File.WriteAllBytes(oldFile, oldData); + File.WriteAllBytes(newFile, newData); + + var differ = new StreamingHdiffDiffer(); + var sw = Stopwatch.StartNew(); + + await differ.CleanAsync(oldFile, newFile, patchFile); + _output.WriteLine($"5MB clean: {sw.ElapsedMilliseconds}ms, patch={new FileInfo(patchFile).Length} bytes"); + + sw.Restart(); + await differ.DirtyAsync(oldFile, restoredFile, patchFile); + _output.WriteLine($"5MB dirty: {sw.ElapsedMilliseconds}ms"); + + Assert.Equal(newData, File.ReadAllBytes(restoredFile)); + } + + [Fact] + public async Task MultiFile_HighParallelism_AllFilesProcessed() + { + var src = Path.Combine(_testDir, "src"); + var tgt = Path.Combine(_testDir, "tgt"); + var app = Path.Combine(_testDir, "app"); + var patchDir = Path.Combine(_testDir, "patches"); + Directory.CreateDirectory(src); + Directory.CreateDirectory(tgt); + Directory.CreateDirectory(app); + Directory.CreateDirectory(patchDir); + + var fileCount = 50; + for (var i = 0; i < fileCount; i++) + { + var oldContent = GenerateBytes(512, i); + var newContent = GenerateBytes(512, i + 1000); + File.WriteAllBytes(Path.Combine(src, $"h_{i:D3}.dll"), oldContent); + File.WriteAllBytes(Path.Combine(tgt, $"h_{i:D3}.dll"), newContent); + File.WriteAllBytes(Path.Combine(app, $"h_{i:D3}.dll"), oldContent); + } + + var pipeline = new DiffPipelineBuilder() + .WithParallelism(Environment.ProcessorCount) + .Build(); + + var sw = Stopwatch.StartNew(); + await pipeline.CleanAsync(src, tgt, patchDir); + _output.WriteLine($"Clean {fileCount} files (x{Environment.ProcessorCount}): {sw.ElapsedMilliseconds}ms"); + + sw.Restart(); + await pipeline.DirtyAsync(app, patchDir); + _output.WriteLine($"Dirty {fileCount} files (x{Environment.ProcessorCount}): {sw.ElapsedMilliseconds}ms"); + + for (var i = 0; i < fileCount; i++) + { + var resultPath = Path.Combine(app, $"h_{i:D3}.dll"); + Assert.True(File.Exists(resultPath)); + Assert.Equal(GenerateBytes(512, i + 1000), File.ReadAllBytes(resultPath)); + } + } + + // ========================================================================= + // Helpers + // ========================================================================= + + private static byte[] GenerateBytes(int size, int seed) + { + var data = new byte[size]; + var rng = new Random(seed); + rng.NextBytes(data); + return data; + } + + private sealed class SyncProgress : IProgress + { + public T LastValue { get; private set; } = default!; + + public void Report(T value) => LastValue = value; + } +} From 5c30265bd308d6596e0dd237e5ef40d00681160d Mon Sep 17 00:00:00 2001 From: JusterZhu Date: Tue, 26 May 2026 20:43:19 +0800 Subject: [PATCH 2/3] trigger CI From b99e554b6f8ac0d2b4353dafcbb3d5d3ad135330 Mon Sep 17 00:00:00 2001 From: JusterZhu Date: Tue, 26 May 2026 20:47:05 +0800 Subject: [PATCH 3/3] fix: address Copilot review suggestions - Remove unused ConcurrentBag variable - Cap Environment.ProcessorCount in tests for stable CI - Strengthen DiffProgress assertions (check Total > 0 and Completed == Total) - Clarify BsdiffDiffer thread-safety doc to mention compression provider contract Co-Authored-By: Claude Opus 4.7 --- .../Differ/BsdiffDiffer.cs | 7 +++++-- .../ComprehensiveDifferentialTests.cs | 14 +++++++------- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/src/c#/GeneralUpdate.Differential/Differ/BsdiffDiffer.cs b/src/c#/GeneralUpdate.Differential/Differ/BsdiffDiffer.cs index 9d0ac61f..115befc7 100644 --- a/src/c#/GeneralUpdate.Differential/Differ/BsdiffDiffer.cs +++ b/src/c#/GeneralUpdate.Differential/Differ/BsdiffDiffer.cs @@ -15,8 +15,11 @@ namespace GeneralUpdate.Differential.Differ /// Default: (backward compatible). /// Use (0x01) for faster decompression. /// - /// Thread-safety: this class is stateless beyond the compression provider. - /// A single instance is safe for concurrent calls. + /// Thread-safety: this class is safe for concurrent calls provided the + /// configured is thread-safe. + /// The built-in providers (, + /// , ) + /// are all safe for concurrent use. /// public class BsdiffDiffer : IBinaryDiffer { diff --git a/tests/DifferentialTest/ComprehensiveDifferentialTests.cs b/tests/DifferentialTest/ComprehensiveDifferentialTests.cs index 891823dc..bbd002fd 100644 --- a/tests/DifferentialTest/ComprehensiveDifferentialTests.cs +++ b/tests/DifferentialTest/ComprehensiveDifferentialTests.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; using System.IO; @@ -395,10 +394,9 @@ public async Task DiffPipeline_ParallelClean_AllFilesGeneratePatches() // Also add some new files in target (no old version) File.WriteAllBytes(Path.Combine(tgt, "new_features.dll"), [1, 2, 3, 4, 5]); - var completedFiles = new ConcurrentBag(); var progress = new SyncProgress(); var pipeline = new DiffPipelineBuilder() - .WithParallelism(Environment.ProcessorCount) + .WithParallelism(Math.Min(Environment.ProcessorCount, 4)) .Build(); // Act @@ -407,7 +405,8 @@ public async Task DiffPipeline_ParallelClean_AllFilesGeneratePatches() _output.WriteLine($"Parallel Clean (30 files): {sw.ElapsedMilliseconds}ms"); // Assert - Assert.True(progress.LastValue.IsComplete); + Assert.True(progress.LastValue.Total > 0, "Progress Total should be > 0"); + Assert.Equal(progress.LastValue.Total, progress.LastValue.Completed); _output.WriteLine($"Progress: {progress.LastValue.Completed}/{progress.LastValue.Total}"); // Each modified file should have a .patch @@ -807,17 +806,18 @@ public async Task MultiFile_HighParallelism_AllFilesProcessed() File.WriteAllBytes(Path.Combine(app, $"h_{i:D3}.dll"), oldContent); } + var parallelism = Math.Min(Environment.ProcessorCount, 4); var pipeline = new DiffPipelineBuilder() - .WithParallelism(Environment.ProcessorCount) + .WithParallelism(parallelism) .Build(); var sw = Stopwatch.StartNew(); await pipeline.CleanAsync(src, tgt, patchDir); - _output.WriteLine($"Clean {fileCount} files (x{Environment.ProcessorCount}): {sw.ElapsedMilliseconds}ms"); + _output.WriteLine($"Clean {fileCount} files (x{parallelism}): {sw.ElapsedMilliseconds}ms"); sw.Restart(); await pipeline.DirtyAsync(app, patchDir); - _output.WriteLine($"Dirty {fileCount} files (x{Environment.ProcessorCount}): {sw.ElapsedMilliseconds}ms"); + _output.WriteLine($"Dirty {fileCount} files (x{parallelism}): {sw.ElapsedMilliseconds}ms"); for (var i = 0; i < fileCount; i++) {