diff --git a/LuYao.ResourcePacker.Tests/ResourcePackageReaderThreadSafetyTests.cs b/LuYao.ResourcePacker.Tests/ResourcePackageReaderThreadSafetyTests.cs new file mode 100644 index 0000000..92b4123 --- /dev/null +++ b/LuYao.ResourcePacker.Tests/ResourcePackageReaderThreadSafetyTests.cs @@ -0,0 +1,241 @@ +using Xunit; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace LuYao.ResourcePacker.Tests +{ + public class ResourcePackageReaderThreadSafetyTests : IDisposable + { + private readonly string _tempDirectory; + private readonly string _outputPath; + + public ResourcePackageReaderThreadSafetyTests() + { + // Create temporary directory for tests + _tempDirectory = Path.Combine(Path.GetTempPath(), $"ResourcePackerThreadSafetyTests_{Guid.NewGuid()}"); + Directory.CreateDirectory(_tempDirectory); + _outputPath = Path.Combine(_tempDirectory, "test.dat"); + + // Create test resource package + var sourceDir = Path.Combine(Directory.GetCurrentDirectory(), "TestResources"); + var packer = new ResourcePacker(sourceDir, "*.res.*"); + packer.PackResources(_outputPath); + } + + [Fact] + public async Task ConcurrentReadResourceAsync_ShouldNotCorruptData() + { + // Arrange + var reader = new ResourcePackageReader(_outputPath); + const int threadCount = 10; + const int iterationsPerThread = 50; + + // Act - Read the same resources concurrently from multiple threads + var tasks = Enumerable.Range(0, threadCount).Select(async _ => + { + for (int i = 0; i < iterationsPerThread; i++) + { + var testContent = await reader.ReadResourceAsStringAsync("test"); + var greetingContent = await reader.ReadResourceAsStringAsync("greeting"); + + // Assert - Verify data integrity + Assert.Contains("Hello, World!", testContent); + Assert.Contains("Hello from resource file!", greetingContent); + } + }).ToArray(); + + await Task.WhenAll(tasks); + } + + [Fact] + public void ConcurrentReadResource_ShouldNotCorruptData() + { + // Arrange + var reader = new ResourcePackageReader(_outputPath); + const int threadCount = 10; + const int iterationsPerThread = 50; + + // Act - Read the same resources concurrently from multiple threads + var tasks = Enumerable.Range(0, threadCount).Select(i => Task.Run(() => + { + for (int j = 0; j < iterationsPerThread; j++) + { + var testContent = reader.ReadResourceAsString("test"); + var greetingContent = reader.ReadResourceAsString("greeting"); + + // Assert - Verify data integrity + Assert.Contains("Hello, World!", testContent); + Assert.Contains("Hello from resource file!", greetingContent); + } + })).ToArray(); + + Task.WaitAll(tasks); + } + + [Fact] + public void ConcurrentReadResourceBytes_ShouldReturnCorrectData() + { + // Arrange + var reader = new ResourcePackageReader(_outputPath); + const int threadCount = 10; + const int iterationsPerThread = 50; + + // Act - Read the same resources concurrently from multiple threads + var tasks = Enumerable.Range(0, threadCount).Select(i => Task.Run(() => + { + for (int j = 0; j < iterationsPerThread; j++) + { + var testBytes = reader.ReadResource("test"); + var greetingBytes = reader.ReadResource("greeting"); + + // Assert - Verify data integrity by converting to string + var testContent = Encoding.UTF8.GetString(testBytes); + var greetingContent = Encoding.UTF8.GetString(greetingBytes); + + Assert.Contains("Hello, World!", testContent); + Assert.Contains("Hello from resource file!", greetingContent); + } + })).ToArray(); + + Task.WaitAll(tasks); + } + + [Fact] + public void ConcurrentGetStream_ShouldNotCorruptData() + { + // Arrange + var reader = new ResourcePackageReader(_outputPath); + const int threadCount = 10; + const int iterationsPerThread = 20; + + // Act - Get streams concurrently from multiple threads + var tasks = Enumerable.Range(0, threadCount).Select(i => Task.Run(() => + { + for (int j = 0; j < iterationsPerThread; j++) + { + using var testStream = reader.GetStream("test"); + using var greetingStream = reader.GetStream("greeting"); + + using var testReader = new StreamReader(testStream); + using var greetingReader = new StreamReader(greetingStream); + + var testContent = testReader.ReadToEnd(); + var greetingContent = greetingReader.ReadToEnd(); + + // Assert - Verify data integrity + Assert.Contains("Hello, World!", testContent); + Assert.Contains("Hello from resource file!", greetingContent); + } + })).ToArray(); + + Task.WaitAll(tasks); + } + + [Fact] + public void MixedConcurrentOperations_ShouldNotCorruptData() + { + // Arrange + var reader = new ResourcePackageReader(_outputPath); + const int threadCount = 15; + const int iterationsPerThread = 30; + + // Act - Mix different read operations concurrently + var tasks = Enumerable.Range(0, threadCount).Select(i => Task.Run(() => + { + for (int j = 0; j < iterationsPerThread; j++) + { + // Alternate between different read methods + switch (j % 3) + { + case 0: + var bytes = reader.ReadResource("test"); + Assert.True(bytes.Length > 0); + break; + case 1: + var content = reader.ReadResourceAsString("greeting"); + Assert.Contains("Hello from resource file!", content); + break; + case 2: + using (var stream = reader.GetStream("test")) + { + using var sr = new StreamReader(stream); + var streamContent = sr.ReadToEnd(); + Assert.Contains("Hello, World!", streamContent); + } + break; + } + } + })).ToArray(); + + Task.WaitAll(tasks); + } + + [Fact] + public async Task ConcurrentReadWithDifferentEncodings_ShouldNotCorruptData() + { + // Arrange + var reader = new ResourcePackageReader(_outputPath); + const int threadCount = 8; + const int iterationsPerThread = 40; + + // Act - Read with different encodings concurrently + var tasks = Enumerable.Range(0, threadCount).Select(async i => + { + for (int j = 0; j < iterationsPerThread; j++) + { + var encoding = (j % 2 == 0) ? Encoding.UTF8 : Encoding.ASCII; + var content = await reader.ReadResourceAsStringAsync("test", encoding); + + // Assert - Verify data integrity + Assert.Contains("Hello, World!", content); + } + }).ToArray(); + + await Task.WhenAll(tasks); + } + + [Fact] + public void ConcurrentStreamReads_ShouldNotCorruptData() + { + // Arrange + var reader = new ResourcePackageReader(_outputPath); + const int threadCount = 10; + + // Act - Multiple threads reading from streams simultaneously + var tasks = Enumerable.Range(0, threadCount).Select(i => Task.Run(() => + { + using var stream = reader.GetStream("test"); + var buffer = new byte[1024]; + var totalRead = 0; + + while (true) + { + var bytesRead = stream.Read(buffer, totalRead, buffer.Length - totalRead); + if (bytesRead == 0) + break; + totalRead += bytesRead; + } + + var content = Encoding.UTF8.GetString(buffer, 0, totalRead); + + // Assert - Verify data integrity + Assert.Contains("Hello, World!", content); + })).ToArray(); + + Task.WaitAll(tasks); + } + + public void Dispose() + { + // Clean up temporary directory + if (Directory.Exists(_tempDirectory)) + { + Directory.Delete(_tempDirectory, true); + } + } + } +} diff --git a/LuYao.ResourcePacker.Tests/ResourcePackerTests.cs b/LuYao.ResourcePacker.Tests/ResourcePackerTests.cs index 8f890d7..c8eea07 100644 --- a/LuYao.ResourcePacker.Tests/ResourcePackerTests.cs +++ b/LuYao.ResourcePacker.Tests/ResourcePackerTests.cs @@ -34,7 +34,7 @@ public async Task PackAndReadResources_ShouldWorkCorrectly() // Assert Assert.True(File.Exists(_outputPath)); - using var reader = new ResourcePackageReader(_outputPath); + var reader = new ResourcePackageReader(_outputPath); var jsonContent = await reader.ReadResourceAsStringAsync("test"); var txtContent = await reader.ReadResourceAsStringAsync("greeting"); @@ -53,7 +53,7 @@ public async Task ResourceKeys_ShouldMatchSourceFiles() packer.PackResources(_outputPath); // Assert - using var reader = new ResourcePackageReader(_outputPath); + var reader = new ResourcePackageReader(_outputPath); var keys = reader.ResourceKeys.ToList(); Assert.Contains("test", keys); @@ -70,28 +70,11 @@ public async Task ReadResource_WithInvalidKey_ShouldThrowException() packer.PackResources(_outputPath); // Act & Assert - using var reader = new ResourcePackageReader(_outputPath); + var reader = new ResourcePackageReader(_outputPath); await Assert.ThrowsAsync(() => reader.ReadResourceAsync("non_existent_key")); } - [Fact] - public async Task ReadResource_AfterDispose_ShouldThrowException() - { - // Arrange - var sourceDir = Path.Combine(Directory.GetCurrentDirectory(), "TestResources"); - var packer = new ResourcePacker(sourceDir, "*.res.*"); - packer.PackResources(_outputPath); - - // Act - var reader = new ResourcePackageReader(_outputPath); - reader.Dispose(); - - // Assert - await Assert.ThrowsAsync(() => - reader.ReadResourceAsync("test")); - } - [Fact] public void PackResources_WithEmptyDirectory_ShouldCreateEmptyPackage() { @@ -104,7 +87,7 @@ public void PackResources_WithEmptyDirectory_ShouldCreateEmptyPackage() packer.PackResources(_outputPath); // Assert - using var reader = new ResourcePackageReader(_outputPath); + var reader = new ResourcePackageReader(_outputPath); Assert.Empty(reader.ResourceKeys); } @@ -140,7 +123,7 @@ public async Task ResourceKeys_ShouldBeSorted() packer.PackResources(_outputPath); // Assert - using var reader = new ResourcePackageReader(_outputPath); + var reader = new ResourcePackageReader(_outputPath); var keys = reader.ResourceKeys.ToList(); var sortedKeys = keys.OrderBy(k => k).ToList(); @@ -207,7 +190,7 @@ public async Task ReadResourceAsStringAsync_WithEncoding_ShouldUseProvidedEncodi packer.PackResources(_outputPath); // Act - using var reader = new ResourcePackageReader(_outputPath); + var reader = new ResourcePackageReader(_outputPath); var content = await reader.ReadResourceAsStringAsync("greeting", Encoding.UTF8); // Assert @@ -223,7 +206,7 @@ public void ReadResource_ShouldReturnByteArraySynchronously() packer.PackResources(_outputPath); // Act - using var reader = new ResourcePackageReader(_outputPath); + var reader = new ResourcePackageReader(_outputPath); var bytes = reader.ReadResource("greeting"); // Assert @@ -242,7 +225,7 @@ public void ReadResourceAsString_ShouldReturnStringSynchronously() packer.PackResources(_outputPath); // Act - using var reader = new ResourcePackageReader(_outputPath); + var reader = new ResourcePackageReader(_outputPath); var content = reader.ReadResourceAsString("greeting"); // Assert @@ -258,7 +241,7 @@ public void ReadResourceAsString_WithEncoding_ShouldUseProvidedEncoding() packer.PackResources(_outputPath); // Act - using var reader = new ResourcePackageReader(_outputPath); + var reader = new ResourcePackageReader(_outputPath); var content = reader.ReadResourceAsString("test", Encoding.UTF8); // Assert @@ -274,7 +257,7 @@ public void GetStream_ShouldReturnReadOnlyStream() packer.PackResources(_outputPath); // Act - using var reader = new ResourcePackageReader(_outputPath); + var reader = new ResourcePackageReader(_outputPath); using var stream = reader.GetStream("greeting"); // Assert @@ -296,62 +279,11 @@ public void GetStream_WithInvalidKey_ShouldThrowException() packer.PackResources(_outputPath); // Act & Assert - using var reader = new ResourcePackageReader(_outputPath); + var reader = new ResourcePackageReader(_outputPath); Assert.Throws(() => reader.GetStream("non_existent_key")); } - [Fact] - public void ReadResourceSync_AfterDispose_ShouldThrowException() - { - // Arrange - var sourceDir = Path.Combine(Directory.GetCurrentDirectory(), "TestResources"); - var packer = new ResourcePacker(sourceDir, "*.res.*"); - packer.PackResources(_outputPath); - - // Act - var reader = new ResourcePackageReader(_outputPath); - reader.Dispose(); - - // Assert - Assert.Throws(() => - reader.ReadResource("test")); - } - - [Fact] - public void ReadResourceAsStringSync_AfterDispose_ShouldThrowException() - { - // Arrange - var sourceDir = Path.Combine(Directory.GetCurrentDirectory(), "TestResources"); - var packer = new ResourcePacker(sourceDir, "*.res.*"); - packer.PackResources(_outputPath); - - // Act - var reader = new ResourcePackageReader(_outputPath); - reader.Dispose(); - - // Assert - Assert.Throws(() => - reader.ReadResourceAsString("test")); - } - - [Fact] - public void GetStream_AfterDispose_ShouldThrowException() - { - // Arrange - var sourceDir = Path.Combine(Directory.GetCurrentDirectory(), "TestResources"); - var packer = new ResourcePacker(sourceDir, "*.res.*"); - packer.PackResources(_outputPath); - - // Act - var reader = new ResourcePackageReader(_outputPath); - reader.Dispose(); - - // Assert - Assert.Throws(() => - reader.GetStream("test")); - } - [Fact] public void ContainsKey_WithExistingKey_ShouldReturnTrue() { @@ -361,7 +293,7 @@ public void ContainsKey_WithExistingKey_ShouldReturnTrue() packer.PackResources(_outputPath); // Act - using var reader = new ResourcePackageReader(_outputPath); + var reader = new ResourcePackageReader(_outputPath); var exists = reader.ContainsKey("test"); // Assert @@ -377,7 +309,7 @@ public void ContainsKey_WithNonExistentKey_ShouldReturnFalse() packer.PackResources(_outputPath); // Act - using var reader = new ResourcePackageReader(_outputPath); + var reader = new ResourcePackageReader(_outputPath); var exists = reader.ContainsKey("non_existent_key"); // Assert diff --git a/LuYao.ResourcePacker/ResourcePackageReader.cs b/LuYao.ResourcePacker/ResourcePackageReader.cs index e4a63dc..792aabe 100644 --- a/LuYao.ResourcePacker/ResourcePackageReader.cs +++ b/LuYao.ResourcePacker/ResourcePackageReader.cs @@ -8,12 +8,12 @@ namespace LuYao.ResourcePacker { /// /// Provides functionality to read resources from a packaged resource file. + /// This class is thread-safe for concurrent read operations by creating independent FileStream instances per operation. /// - public class ResourcePackageReader : IDisposable + public class ResourcePackageReader { - private readonly FileStream _fileStream; + private readonly string _filePath; private readonly Dictionary _resourceIndex; - private bool _disposed; /// /// Initializes a new instance of the class. @@ -21,14 +21,15 @@ public class ResourcePackageReader : IDisposable /// The path to the resource package file. public ResourcePackageReader(string filePath) { - _fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read); + _filePath = filePath ?? throw new ArgumentNullException(nameof(filePath)); _resourceIndex = new Dictionary(); LoadIndex(); } private void LoadIndex() { - using var reader = new BinaryReader(_fileStream, System.Text.Encoding.UTF8, leaveOpen: true); + using var fileStream = new FileStream(_filePath, FileMode.Open, FileAccess.Read, FileShare.Read); + using var reader = new BinaryReader(fileStream, System.Text.Encoding.UTF8, leaveOpen: false); // Read version number var version = reader.ReadByte(); @@ -50,7 +51,7 @@ private void LoadIndex() } // Calculate offsets based on the current position - long currentOffset = _fileStream.Position; + long currentOffset = fileStream.Position; foreach (var (key, length) in indexEntries) { _resourceIndex[key] = new ResourceEntry @@ -82,18 +83,29 @@ public bool ContainsKey(string resourceKey) /// /// The key of the resource to read. /// A task that represents the asynchronous read operation. - public async Task ReadResourceAsync(string resourceKey) + public Task ReadResourceAsync(string resourceKey) { - if (_disposed) - throw new ObjectDisposedException(nameof(ResourcePackageReader)); - if (!_resourceIndex.TryGetValue(resourceKey, out var entry)) throw new KeyNotFoundException($"Resource with key '{resourceKey}' not found."); var buffer = new byte[entry.Length]; - _fileStream.Seek(entry.Offset, SeekOrigin.Begin); - await _fileStream.ReadAsync(buffer, 0, buffer.Length); - return buffer; + + // Create a new FileStream for this read operation (thread-safe via FileShare.Read) + using (var fileStream = new FileStream(_filePath, FileMode.Open, FileAccess.Read, FileShare.Read)) + { + fileStream.Seek(entry.Offset, SeekOrigin.Begin); + + int totalRead = 0; + while (totalRead < entry.Length) + { + int bytesRead = fileStream.Read(buffer, totalRead, entry.Length - totalRead); + if (bytesRead == 0) + throw new EndOfStreamException($"Unexpected end of stream while reading resource '{resourceKey}'."); + totalRead += bytesRead; + } + } + + return Task.FromResult(buffer); } /// @@ -126,22 +138,24 @@ public async Task ReadResourceAsStringAsync(string resourceKey, Encoding /// The resource data as a byte array. public byte[] ReadResource(string resourceKey) { - if (_disposed) - throw new ObjectDisposedException(nameof(ResourcePackageReader)); - if (!_resourceIndex.TryGetValue(resourceKey, out var entry)) throw new KeyNotFoundException($"Resource with key '{resourceKey}' not found."); var buffer = new byte[entry.Length]; - _fileStream.Seek(entry.Offset, SeekOrigin.Begin); - int totalRead = 0; - while (totalRead < entry.Length) + // Create a new FileStream for this read operation (thread-safe via FileShare.Read) + using (var fileStream = new FileStream(_filePath, FileMode.Open, FileAccess.Read, FileShare.Read)) { - int bytesRead = _fileStream.Read(buffer, totalRead, entry.Length - totalRead); - if (bytesRead == 0) - throw new EndOfStreamException($"Unexpected end of stream while reading resource '{resourceKey}'."); - totalRead += bytesRead; + fileStream.Seek(entry.Offset, SeekOrigin.Begin); + + int totalRead = 0; + while (totalRead < entry.Length) + { + int bytesRead = fileStream.Read(buffer, totalRead, entry.Length - totalRead); + if (bytesRead == 0) + throw new EndOfStreamException($"Unexpected end of stream while reading resource '{resourceKey}'."); + totalRead += bytesRead; + } } return buffer; @@ -172,31 +186,17 @@ public string ReadResourceAsString(string resourceKey, Encoding encoding) /// /// Gets a read-only stream for the specified resource. + /// This allows streaming large resources without loading all data into memory. /// /// The key of the resource to read. /// A read-only stream containing the resource data. public Stream GetStream(string resourceKey) { - if (_disposed) - throw new ObjectDisposedException(nameof(ResourcePackageReader)); - if (!_resourceIndex.TryGetValue(resourceKey, out var entry)) throw new KeyNotFoundException($"Resource with key '{resourceKey}' not found."); - // Create a SubStream wrapper that provides a read-only view of a portion of the file - return new SubStream(_fileStream, entry.Offset, entry.Length); - } - - /// - /// Releases the resources used by the . - /// - public void Dispose() - { - if (!_disposed) - { - _fileStream?.Dispose(); - _disposed = true; - } + // Create a SubStream for streaming access without loading entire resource into memory + return new ResourceSubStream(_filePath, entry.Offset, entry.Length); } } @@ -207,34 +207,49 @@ internal class ResourceEntry } /// - /// A stream wrapper that provides a read-only view of a portion of another stream. + /// A read-only stream that provides access to a portion of a resource package file. + /// This allows streaming large resources without loading all data into memory. + /// Each instance creates its own FileStream for thread-safe operation. /// - internal class SubStream : Stream + internal class ResourceSubStream : Stream { - private readonly Stream _baseStream; - private readonly long _offset; - private readonly long _length; + private readonly string _filePath; + private readonly long _resourceOffset; + private readonly long _resourceLength; private long _position; + private FileStream? _fileStream; + private bool _disposed; - public SubStream(Stream baseStream, long offset, long length) + public ResourceSubStream(string filePath, long offset, long length) { - _baseStream = baseStream ?? throw new ArgumentNullException(nameof(baseStream)); - _offset = offset; - _length = length; + _filePath = filePath ?? throw new ArgumentNullException(nameof(filePath)); + _resourceOffset = offset; + _resourceLength = length; _position = 0; } - public override bool CanRead => true; - public override bool CanSeek => true; + private FileStream EnsureFileStream() + { + if (_fileStream == null) + { + _fileStream = new FileStream(_filePath, FileMode.Open, FileAccess.Read, FileShare.Read); + } + return _fileStream; + } + + public override bool CanRead => !_disposed; + public override bool CanSeek => !_disposed; public override bool CanWrite => false; - public override long Length => _length; + public override long Length => _resourceLength; public override long Position { get => _position; set { - if (value < 0 || value > _length) + if (_disposed) + throw new ObjectDisposedException(nameof(ResourceSubStream)); + if (value < 0 || value > _resourceLength) throw new ArgumentOutOfRangeException(nameof(value)); _position = value; } @@ -242,6 +257,8 @@ public override long Position public override int Read(byte[] buffer, int offset, int count) { + if (_disposed) + throw new ObjectDisposedException(nameof(ResourceSubStream)); if (buffer == null) throw new ArgumentNullException(nameof(buffer)); if (offset < 0) @@ -251,14 +268,15 @@ public override int Read(byte[] buffer, int offset, int count) if (buffer.Length - offset < count) throw new ArgumentException("Invalid offset/count combination"); - long remaining = _length - _position; + long remaining = _resourceLength - _position; if (remaining <= 0) return 0; int toRead = (int)Math.Min(count, remaining); - _baseStream.Seek(_offset + _position, SeekOrigin.Begin); - int bytesRead = _baseStream.Read(buffer, offset, toRead); + var fs = EnsureFileStream(); + fs.Seek(_resourceOffset + _position, SeekOrigin.Begin); + int bytesRead = fs.Read(buffer, offset, toRead); _position += bytesRead; return bytesRead; @@ -266,15 +284,18 @@ public override int Read(byte[] buffer, int offset, int count) public override long Seek(long offset, SeekOrigin origin) { + if (_disposed) + throw new ObjectDisposedException(nameof(ResourceSubStream)); + long newPosition = origin switch { SeekOrigin.Begin => offset, SeekOrigin.Current => _position + offset, - SeekOrigin.End => _length + offset, + SeekOrigin.End => _resourceLength + offset, _ => throw new ArgumentException("Invalid seek origin", nameof(origin)) }; - if (newPosition < 0 || newPosition > _length) + if (newPosition < 0 || newPosition > _resourceLength) throw new ArgumentOutOfRangeException(nameof(offset)); _position = newPosition; @@ -295,5 +316,18 @@ public override void Write(byte[] buffer, int offset, int count) { throw new NotSupportedException("Cannot write to a read-only stream."); } + + protected override void Dispose(bool disposing) + { + if (!_disposed) + { + if (disposing) + { + _fileStream?.Dispose(); + } + _disposed = true; + } + base.Dispose(disposing); + } } } \ No newline at end of file diff --git a/examples/ExampleProject/Program.cs b/examples/ExampleProject/Program.cs index 5bc8c8c..b556379 100644 --- a/examples/ExampleProject/Program.cs +++ b/examples/ExampleProject/Program.cs @@ -22,7 +22,7 @@ static async Task Main(string[] args) return; } - using var reader = new ResourcePackageReader(datFilePath); + var reader = new ResourcePackageReader(datFilePath); Console.WriteLine($"Resource package loaded: {datFilePath}"); Console.WriteLine();