diff --git a/src/libraries/System.Memory/ref/System.Memory.cs b/src/libraries/System.Memory/ref/System.Memory.cs index c665b746232878..4a9c2b70c6deb4 100644 --- a/src/libraries/System.Memory/ref/System.Memory.cs +++ b/src/libraries/System.Memory/ref/System.Memory.cs @@ -160,6 +160,24 @@ public void Rewind(long count) { } public bool TryReadExact(int count, out System.Buffers.ReadOnlySequence sequence) { throw null; } } } +namespace System.Buffers +{ + public sealed partial class ReadOnlySequenceStream : System.IO.Stream + { + public ReadOnlySequenceStream(System.Buffers.ReadOnlySequence sequence) { } + public override bool CanRead { get { throw null; } } + public override bool CanSeek { get { throw null; } } + public override bool CanWrite { get { throw null; } } + public override long Length { get { throw null; } } + public override long Position { get { throw null; } set { } } + public override void Flush() { } + public override int Read(byte[] buffer, int offset, int count) { throw null; } + public override int Read(System.Span buffer) { throw null; } + public override long Seek(long offset, System.IO.SeekOrigin origin) { throw null; } + public override void SetLength(long value) { } + public override void Write(byte[] buffer, int offset, int count) { } + } +} namespace System.Runtime.InteropServices { public static partial class SequenceMarshal diff --git a/src/libraries/System.Memory/src/Resources/Strings.resx b/src/libraries/System.Memory/src/Resources/Strings.resx index 8576ac8e8642cc..90489cb74c5898 100644 --- a/src/libraries/System.Memory/src/Resources/Strings.resx +++ b/src/libraries/System.Memory/src/Resources/Strings.resx @@ -147,4 +147,13 @@ Cannot allocate a buffer of size {0}. + + Stream does not support writing. + + + An attempt was made to move the position before the beginning of the stream. + + + Invalid seek origin. + \ No newline at end of file diff --git a/src/libraries/System.Memory/src/System.Memory.csproj b/src/libraries/System.Memory/src/System.Memory.csproj index b7b0772895e5f7..975f843676df18 100644 --- a/src/libraries/System.Memory/src/System.Memory.csproj +++ b/src/libraries/System.Memory/src/System.Memory.csproj @@ -1,4 +1,4 @@ - + $(NetCoreAppCurrent) @@ -26,6 +26,7 @@ + diff --git a/src/libraries/System.Memory/src/System/Buffers/ReadOnlySequenceStream.cs b/src/libraries/System.Memory/src/System/Buffers/ReadOnlySequenceStream.cs new file mode 100644 index 00000000000000..3e6423880dfd41 --- /dev/null +++ b/src/libraries/System.Memory/src/System/Buffers/ReadOnlySequenceStream.cs @@ -0,0 +1,203 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +using System.Threading; +using System.IO; +using System.Threading.Tasks; + +namespace System.Buffers +{ + /// + /// Provides a seekable, read-only implementation over a of bytes. + /// + /// + /// This type is not thread-safe. Synchronize access if the stream is used concurrently. + /// The underlying sequence should not be modified while the stream is in use. + /// Seeking beyond the end of the stream is supported; subsequent reads will return zero bytes. + /// + // Seekable Stream from ReadOnlySequence + public sealed class ReadOnlySequenceStream : Stream + { + private ReadOnlySequence _sequence; + private SequencePosition _position; + private long _positionPastEnd; // -1 if within bounds, or the actual position if past end + private bool _isDisposed; + + /// + /// Initializes a new instance of the class over the specified . + /// + /// The to wrap. + public ReadOnlySequenceStream(ReadOnlySequence sequence) + { + _sequence = sequence; + _position = sequence.Start; + _positionPastEnd = -1; + _isDisposed = false; + } + + /// + public override bool CanRead => !_isDisposed; + + /// + public override bool CanSeek => !_isDisposed; + + /// + public override bool CanWrite => false; + + private void EnsureNotDisposed() => ObjectDisposedException.ThrowIf(_isDisposed, this); + + /// + public override long Length + { + get + { + EnsureNotDisposed(); + return _sequence.Length; + } + } + + /// + public override long Position + { + get + { + EnsureNotDisposed(); + return _positionPastEnd >= 0 ? _positionPastEnd : _sequence.Slice(_sequence.Start, _position).Length; + } + set + { + EnsureNotDisposed(); + ArgumentOutOfRangeException.ThrowIfNegative(value); + + // Allow seeking past the end + if (value >= Length) + { + _position = _sequence.End; + _positionPastEnd = value; + } + else + { + _position = _sequence.GetPosition(value, _sequence.Start); + _positionPastEnd = -1; + } + } + } + + /// + public override int Read(byte[] buffer, int offset, int count) + { + ValidateBufferArguments(buffer, offset, count); + return Read(buffer.AsSpan(offset, count)); + } + + /// + public override int Read(Span buffer) + { + EnsureNotDisposed(); + + if (_positionPastEnd >= 0) + { + return 0; + } + + ReadOnlySequence remaining = _sequence.Slice(_position); + int n = (int)Math.Min(remaining.Length, buffer.Length); + if (n <= 0) + { + return 0; + } + + remaining.Slice(0, n).CopyTo(buffer); + _position = _sequence.GetPosition(n, _position); + return n; + } + + /// + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + ValidateBufferArguments(buffer, offset, count); + + // If cancellation was requested, bail early + if (cancellationToken.IsCancellationRequested) + return Task.FromCanceled(cancellationToken); + + int n = Read(buffer, offset, count); + return Task.FromResult(n); + } + + /// + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + if (cancellationToken.IsCancellationRequested) + { + return ValueTask.FromCanceled(cancellationToken); + } + + int bytesRead = Read(buffer.Span); + return new ValueTask(bytesRead); + } + + /// + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(SR.NotSupported_UnwritableStream); + + /// + public override void Write(ReadOnlySpan buffer) => throw new NotSupportedException(SR.NotSupported_UnwritableStream); + + /// + public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) => throw new NotSupportedException(SR.NotSupported_UnwritableStream); + + /// + public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) => throw new NotSupportedException(SR.NotSupported_UnwritableStream); + + /// + /// Sets the position within the current stream. + /// + /// A byte offset relative to the parameter. + /// A value of type indicating the reference point used to obtain the new position. + /// The new position within the stream. + public override long Seek(long offset, SeekOrigin origin) + { + EnsureNotDisposed(); + + long absolutePosition = origin switch + { + SeekOrigin.Begin => offset, + SeekOrigin.Current => (_positionPastEnd >= 0 ? _positionPastEnd : _sequence.Slice(_sequence.Start, _position).Length) + offset, + SeekOrigin.End => Length + offset, + _ => throw new ArgumentException(SR.Argument_InvalidSeekOrigin, nameof(origin)) + }; + + // Negative positions are invalid + if (absolutePosition < 0) + { + throw new IOException(SR.IO_SeekBeforeBegin); + } + + // Update position - seeking past end is allowed + if (absolutePosition >= Length) + { + _position = _sequence.End; + _positionPastEnd = absolutePosition; + } + else + { + _position = _sequence.GetPosition(absolutePosition, _sequence.Start); + _positionPastEnd = -1; + } + + return absolutePosition; + } + + /// + public override void Flush() { } + + /// + public override void SetLength(long value) => throw new NotSupportedException(SR.NotSupported_UnwritableStream); + + /// + protected override void Dispose(bool disposing) + { + _isDisposed = true; + base.Dispose(disposing); + } + } +} diff --git a/src/libraries/System.Memory/tests/ReadOnlyBuffer/ReadOnlySequenceStream.ConformanceTests.cs b/src/libraries/System.Memory/tests/ReadOnlyBuffer/ReadOnlySequenceStream.ConformanceTests.cs new file mode 100644 index 00000000000000..3b5cc139be7ff6 --- /dev/null +++ b/src/libraries/System.Memory/tests/ReadOnlyBuffer/ReadOnlySequenceStream.ConformanceTests.cs @@ -0,0 +1,46 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +using System.IO; +using System.Buffers; +using System.IO.Tests; +using System.Threading.Tasks; + +namespace System.Memory.Tests +{ + /// + /// Conformance tests for ReadOnlySequenceStream - a read-only, seekable stream + /// wrapper around ReadOnlySequence{byte}. + /// + public class ROSequenceStreamConformanceTests : StandaloneStreamConformanceTests + { + // StreamConformanceTests flags to specify capabilities + protected override bool CanSeek => true; + // SetLength() is not supported because ReadOnlySequence{byte} is immutable. + protected override bool CanSetLength => false; + // ReadOnlySequenceStream doesn't buffer writes (it's read-only), + protected override bool NopFlushCompletesSynchronously => true; + + protected override Task CreateReadOnlyStreamCore(byte[]? initialData) + { + if (initialData == null || initialData.Length == 0) + { + // Create empty sequence for null or empty data + var emptySequence = ReadOnlySequence.Empty; + return Task.FromResult(new ReadOnlySequenceStream(emptySequence)); + } + + // ReadOnlySequence can be constructed from: + // 1. ReadOnlyMemory (single segment) + // 2. ReadOnlySequenceSegment chain (multi-segment) + var sequence = new ReadOnlySequence(initialData); // Single segment + return Task.FromResult(new ReadOnlySequenceStream(sequence)); + } + + // Immutable + protected override Task CreateWriteOnlyStreamCore(byte[]? initialData) => Task.FromResult(null); + + + // Immutable + protected override Task CreateReadWriteStreamCore(byte[]? initialData) => Task.FromResult(null); + } +} diff --git a/src/libraries/System.Memory/tests/ReadOnlyBuffer/ReadOnlySequenceStreamTests.cs b/src/libraries/System.Memory/tests/ReadOnlyBuffer/ReadOnlySequenceStreamTests.cs new file mode 100644 index 00000000000000..17abff98bfe5d5 --- /dev/null +++ b/src/libraries/System.Memory/tests/ReadOnlyBuffer/ReadOnlySequenceStreamTests.cs @@ -0,0 +1,252 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +using System.Buffers; +using System.IO; +using System.Threading.Tasks; +using Xunit; + +namespace System.Memory.Tests +{ + /// + /// Additional specific tests for ReadOnlySequenceStream beyond conformance tests. + /// + public class ReadOnlySequenceStreamTests + { + // NOTE: Conformance tests' coverage: Ctor correctness, stream capabilities, + // Position, Length, Seek, Read, exceptions for unsupported operations. + + // Not covered in conformance tests: Stream + multi-segment sequences + // ReadOnlySequence{byte} can represent data spread across + // multiple memory segments (linked list of ReadOnlyMemory{byte}). + // This is common in network buffers and pooled memory scenarios. + [Fact] + public void Read_MultiSegmentSequence_ReturnsCorrectData() + { + // Create multi-segment sequence: [1,2,3] -> [4,5,6] -> [7,8,9] + var segment1 = new TestSegment(new byte[] { 1, 2, 3 }); + var segment2 = segment1.Append(new byte[] { 4, 5, 6 }); + var segment3 = segment2.Append(new byte[] { 7, 8, 9 }); + + var sequence = new ReadOnlySequence(segment1, 0, segment3, 3); + var stream = new ReadOnlySequenceStream(sequence); + + // Read all data + byte[] buffer = new byte[9]; + int totalRead = 0; + + while (totalRead < 9) + { + int bytesRead = stream.Read(buffer, totalRead, 9 - totalRead); + if (bytesRead == 0) break; + totalRead += bytesRead; + } + + Assert.Equal(9, totalRead); + Assert.Equal(new byte[] { 1, 2, 3, 4, 5, 6, 7, 8, 9 }, buffer); + } + + [Fact] + public void Seek_MultiSegmentSequence_WorksCorrectly() + { + // Create multi-segment sequence: [1,2,3] -> [4,5,6] + var segment1 = new TestSegment(new byte[] { 1, 2, 3 }); + var segment2 = segment1.Append(new byte[] { 4, 5, 6 }); + + var sequence = new ReadOnlySequence(segment1, 0, segment2, 3); + var stream = new ReadOnlySequenceStream(sequence); + + // Seek into second segment + stream.Seek(4, SeekOrigin.Begin); // Should be at byte '5' + + byte[] buffer = new byte[1]; + stream.Read(buffer, 0, 1); + + Assert.Equal(5, buffer[0]); + Assert.Equal(5, stream.Position); + } + + [Fact] + public void Seek_AcrossSegments_BothDirections() + { + // Arrange: [10,20,30] -> [40,50,60] + var segment1 = new TestSegment(new byte[] { 10, 20, 30 }); + var segment2 = segment1.Append(new byte[] { 40, 50, 60 }); + + var sequence = new ReadOnlySequence(segment1, 0, segment2, 3); + var stream = new ReadOnlySequenceStream(sequence); + + byte[] buffer = new byte[1]; + + // Act & Assert: Start at position 2 (byte 30) + stream.Position = 2; + stream.Read(buffer, 0, 1); + Assert.Equal(30, buffer[0]); + + // Seek forward into segment 2 + stream.Seek(2, SeekOrigin.Current); // Now at position 5 (byte 60) + stream.Read(buffer, 0, 1); + Assert.Equal(60, buffer[0]); + + // Seek backward into segment 1 + stream.Seek(-4, SeekOrigin.Current); // Now at position 2 (byte 30) + stream.Read(buffer, 0, 1); + Assert.Equal(30, buffer[0]); + } + + [Fact] + public void Position_MultiSegmentSequence_TracksCorrectly() + { + // Arrange: [1,2] -> [3,4] -> [5,6] + var segment1 = new TestSegment(new byte[] { 1, 2 }); + var segment2 = segment1.Append(new byte[] { 3, 4 }); + var segment3 = segment2.Append(new byte[] { 5, 6 }); + + var sequence = new ReadOnlySequence(segment1, 0, segment3, 2); + var stream = new ReadOnlySequenceStream(sequence); + + byte[] buffer = new byte[1]; + + // Act & Assert: Position advances correctly through segments + Assert.Equal(0, stream.Position); + + stream.Read(buffer, 0, 1); // Read from segment 1 + Assert.Equal(1, stream.Position); + + stream.Read(buffer, 0, 1); // Read from segment 1 + Assert.Equal(2, stream.Position); + + stream.Read(buffer, 0, 1); // Read from segment 2 (boundary cross) + Assert.Equal(3, stream.Position); + + stream.Read(buffer, 0, 1); // Read from segment 2 + Assert.Equal(4, stream.Position); + + stream.Read(buffer, 0, 1); // Read from segment 3 (boundary cross) + Assert.Equal(5, stream.Position); + + stream.Read(buffer, 0, 1); // Read from segment 3 + Assert.Equal(6, stream.Position); + } + + /// + /// Helper class for creating multi-segment ReadOnlySequence{byte} for testing. + /// + private class TestSegment : ReadOnlySequenceSegment + { + public TestSegment(byte[] data) + { + Memory = data; + } + + public TestSegment Append(byte[] data) + { + var segment = new TestSegment(data) + { + RunningIndex = RunningIndex + Memory.Length + }; + Next = segment; + return segment; + } + } + + // Basic edge cases + [Fact] + public void Read_ZeroBytes_ReturnsZero() + { + var data = new byte[] { 1, 2, 3 }; + var stream = new ReadOnlySequenceStream(new ReadOnlySequence(data)); + byte[] buffer = new byte[10]; + + int bytesRead = stream.Read(buffer, 0, 0); + + Assert.Equal(0, bytesRead); + Assert.Equal(0, stream.Position); // Position shouldn't change + } + + [Fact] + public void EmptySequence_BehavesCorrectly() + { + var stream = new ReadOnlySequenceStream(ReadOnlySequence.Empty); + + Assert.Equal(0, stream.Length); + Assert.Equal(0, stream.Position); + + byte[] buffer = new byte[10]; + int bytesRead = stream.Read(buffer, 0, 10); + Assert.Equal(0, bytesRead); + + // Seek to position 0 should succeed + stream.Seek(0, SeekOrigin.Begin); + Assert.Equal(0, stream.Position); + + // Seeking beyond empty buffer is allowed + long newPosition = stream.Seek(1, SeekOrigin.Begin); + Assert.Equal(1, newPosition); + Assert.Equal(1, stream.Position); + } + + [Fact] + public async Task ReadAsync_SameResultSize_ReusesCachedTask() + { + var data = new byte[20]; + for (int i = 0; i < 20; i++) data[i] = (byte)i; + var stream = new ReadOnlySequenceStream(new ReadOnlySequence(data)); + + byte[] buffer1 = new byte[5]; + byte[] buffer2 = new byte[5]; + byte[] buffer3 = new byte[5]; + + Task task1 = stream.ReadAsync(buffer1, 0, 5); + Task task2 = stream.ReadAsync(buffer2, 0, 5); + Task task3 = stream.ReadAsync(buffer3, 0, 5); + + await task1; + await task2; + await task3; + + Assert.Same(task1, task2); + Assert.Same(task2, task3); + + Assert.Equal(new byte[] { 0, 1, 2, 3, 4 }, buffer1); + Assert.Equal(new byte[] { 5, 6, 7, 8, 9 }, buffer2); + Assert.Equal(new byte[] { 10, 11, 12, 13, 14 }, buffer3); + } + + [Fact] + public async Task ReadAsync_DifferentResultSize_CreatesNewTask() + { + var data = new byte[10]; + for (int i = 0; i < 10; i++) data[i] = (byte)i; + var stream = new ReadOnlySequenceStream(new ReadOnlySequence(data)); + + byte[] buffer1 = new byte[5]; + byte[] buffer2 = new byte[3]; + byte[] buffer3 = new byte[2]; + + Task task1 = stream.ReadAsync(buffer1, 0, 5); // Returns 5 + Task task2 = stream.ReadAsync(buffer2, 0, 3); // Returns 3 + Task task3 = stream.ReadAsync(buffer3, 0, 2); // Returns 2 + + await task1; + await task2; + await task3; + + Assert.NotSame(task1, task2); + Assert.NotSame(task2, task3); + } + + [Fact] + public async Task ReadAsync_ArrayBackedMemory_UsesFastPath() + { + var data = new byte[] { 10, 20, 30, 40, 50 }; + var stream = new ReadOnlySequenceStream(new ReadOnlySequence(data)); + + byte[] arrayBuffer = new byte[3]; + Memory memory = arrayBuffer.AsMemory(); + int bytesRead = await stream.ReadAsync(memory); + + Assert.Equal(3, bytesRead); + Assert.Equal(new byte[] { 10, 20, 30 }, arrayBuffer); + } + } +} diff --git a/src/libraries/System.Memory/tests/System.Memory.Tests.csproj b/src/libraries/System.Memory/tests/System.Memory.Tests.csproj index 2de051c81fbdba..f529355a9cdf60 100644 --- a/src/libraries/System.Memory/tests/System.Memory.Tests.csproj +++ b/src/libraries/System.Memory/tests/System.Memory.Tests.csproj @@ -1,4 +1,4 @@ - + true true @@ -132,6 +132,8 @@ + + @@ -287,4 +289,7 @@ + + + diff --git a/src/libraries/System.Private.CoreLib/src/Resources/Strings.resx b/src/libraries/System.Private.CoreLib/src/Resources/Strings.resx index 26afeaea117eca..5ddfe90a2250dc 100644 --- a/src/libraries/System.Private.CoreLib/src/Resources/Strings.resx +++ b/src/libraries/System.Private.CoreLib/src/Resources/Strings.resx @@ -3062,6 +3062,9 @@ This operation is invalid on overlapping buffers. + + Stream resynchronization exceeded maximum iterations. + The operation cannot be performed when TimeProvider.LocalTimeZone is null. diff --git a/src/libraries/System.Private.CoreLib/src/System.Private.CoreLib.Shared.projitems b/src/libraries/System.Private.CoreLib/src/System.Private.CoreLib.Shared.projitems index 1fc5d97aebdf3c..97832b41d4a572 100644 --- a/src/libraries/System.Private.CoreLib/src/System.Private.CoreLib.Shared.projitems +++ b/src/libraries/System.Private.CoreLib/src/System.Private.CoreLib.Shared.projitems @@ -529,6 +529,9 @@ + + + @@ -2979,4 +2982,4 @@ - + \ No newline at end of file diff --git a/src/libraries/System.Private.CoreLib/src/System/IO/ReadOnlyMemoryStream.cs b/src/libraries/System.Private.CoreLib/src/System/IO/ReadOnlyMemoryStream.cs new file mode 100644 index 00000000000000..8736ab6a072657 --- /dev/null +++ b/src/libraries/System.Private.CoreLib/src/System/IO/ReadOnlyMemoryStream.cs @@ -0,0 +1,205 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Threading; +using System.Threading.Tasks; + +namespace System.IO; + +/// +/// Provides a seekable, read-only over a . +/// +/// +/// This type is not thread-safe. Synchronize access if the stream is used concurrently. +/// The stream cannot be written to. always returns . +/// +public sealed class ReadOnlyMemoryStream : Stream +{ + private ReadOnlyMemory _buffer; + private int _position; + private bool _isOpen; + + /// + /// Initializes a new instance of the class over the specified . + /// + /// The to wrap. + public ReadOnlyMemoryStream(ReadOnlyMemory source) + { + _buffer = source; + _isOpen = true; + } + + /// + public override bool CanRead => _isOpen; + + /// + public override bool CanSeek => _isOpen; + + /// + public override bool CanWrite => false; + + /// + public override long Length + { + get + { + EnsureNotClosed(); + + return _buffer.Length; + } + } + + /// + public override long Position + { + get + { + EnsureNotClosed(); + + return _position; + } + set + { + EnsureNotClosed(); + ArgumentOutOfRangeException.ThrowIfNegative(value); + ArgumentOutOfRangeException.ThrowIfGreaterThan(value, int.MaxValue); + _position = (int)value; + } + } + + /// + public override int ReadByte() + { + EnsureNotClosed(); + + if (_position >= _buffer.Length) + return -1; + + return _buffer.Span[_position++]; + } + + /// + public override int Read(byte[] buffer, int offset, int count) + { + ValidateBufferArguments(buffer, offset, count); + + return Read(new Span(buffer, offset, count)); + } + + /// + public override int Read(Span buffer) + { + EnsureNotClosed(); + + int remaining = _buffer.Length - _position; + if (remaining <= 0 || buffer.Length == 0) + return 0; + + int bytesToRead = Math.Min(remaining, buffer.Length); + _buffer.Span.Slice(_position, bytesToRead).CopyTo(buffer); + _position += bytesToRead; + + return bytesToRead; + } + + /// + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + ValidateBufferArguments(buffer, offset, count); + + if (cancellationToken.IsCancellationRequested) + return Task.FromCanceled(cancellationToken); + + return Task.FromResult(Read(buffer, offset, count)); + } + + /// + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + if (cancellationToken.IsCancellationRequested) + return ValueTask.FromCanceled(cancellationToken); + + return new ValueTask(Read(buffer.Span)); + } + + /// + public override void CopyTo(Stream destination, int bufferSize) + { + ValidateCopyToArguments(destination, bufferSize); + EnsureNotClosed(); + + if (_buffer.Length > _position) + { + destination.Write(_buffer.Span.Slice(_position)); + _position = _buffer.Length; + } + } + + /// + public override Task CopyToAsync(Stream destination, int bufferSize, CancellationToken cancellationToken) + { + ValidateCopyToArguments(destination, bufferSize); + EnsureNotClosed(); + + if (_buffer.Length > _position) + { + ReadOnlyMemory content = _buffer.Slice(_position); + _position = _buffer.Length; + + return destination.WriteAsync(content, cancellationToken).AsTask(); + } + + return Task.CompletedTask; + } + + /// + public override long Seek(long offset, SeekOrigin origin) + { + EnsureNotClosed(); + + long newPosition = origin switch + { + SeekOrigin.Begin => offset, + SeekOrigin.Current => _position + offset, + SeekOrigin.End => _buffer.Length + offset, + _ => throw new ArgumentException(SR.Argument_InvalidSeekOrigin) + }; + + if (newPosition < 0) + throw new IOException(SR.IO_SeekBeforeBegin); + + ArgumentOutOfRangeException.ThrowIfGreaterThan(newPosition, int.MaxValue, nameof(offset)); + + _position = (int)newPosition; + + return newPosition; + } + + /// + public override void SetLength(long value) => throw new NotSupportedException(SR.NotSupported_UnwritableStream); + + /// + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(SR.NotSupported_UnwritableStream); + + /// + public override void Write(ReadOnlySpan buffer) => throw new NotSupportedException(SR.NotSupported_UnwritableStream); + + /// + public override void Flush() { } + + /// + public override Task FlushAsync(CancellationToken cancellationToken) => + cancellationToken.IsCancellationRequested ? Task.FromCanceled(cancellationToken) : Task.CompletedTask; + + /// + protected override void Dispose(bool disposing) + { + _isOpen = false; + base.Dispose(disposing); + } + + private void EnsureNotClosed() + { + ObjectDisposedException.ThrowIf(!_isOpen, this); + } +} diff --git a/src/libraries/System.Private.CoreLib/src/System/IO/StringStream.cs b/src/libraries/System.Private.CoreLib/src/System/IO/StringStream.cs new file mode 100644 index 00000000000000..887aea3f5edbde --- /dev/null +++ b/src/libraries/System.Private.CoreLib/src/System/IO/StringStream.cs @@ -0,0 +1,162 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace System.IO; + +/// +/// Provides a read-only, non-seekable that encodes a or +/// into bytes on-the-fly using a specified . +/// +/// +/// This stream never emits a byte order mark (BOM). Callers who need a BOM can prepend it themselves. +/// This type is not thread-safe. Synchronize access if the stream is used concurrently. +/// +public sealed class StringStream : Stream +{ + private readonly ReadOnlyMemory _text; + private readonly Encoder _encoder; + private readonly Encoding _encoding; + private int _charPosition; + private bool _disposed; + + /// + /// Initializes a new instance of the class with the specified string and encoding. + /// + /// The string to read from. + /// The encoding to use when converting the string to bytes. + /// or is . + public StringStream(string text, Encoding encoding) + { + ArgumentNullException.ThrowIfNull(text); + ArgumentNullException.ThrowIfNull(encoding); + + _text = text.AsMemory(); + _encoding = encoding; + _encoder = encoding.GetEncoder(); + } + + /// + /// Initializes a new instance of the class with the specified character memory and encoding. + /// + /// The character memory to read from. + /// The encoding to use when converting the characters to bytes. + /// is . + public StringStream(ReadOnlyMemory text, Encoding encoding) + { + ArgumentNullException.ThrowIfNull(encoding); + + _text = text; + _encoding = encoding; + _encoder = encoding.GetEncoder(); + } + + /// + /// Gets the encoding used by this stream. + /// + public Encoding Encoding => _encoding; + + /// + public override bool CanRead => !_disposed; + + /// + public override bool CanSeek => false; + + /// + public override bool CanWrite => false; + + /// + public override long Length => throw new NotSupportedException(SR.NotSupported_UnseekableStream); + + /// + public override long Position + { + get => throw new NotSupportedException(SR.NotSupported_UnseekableStream); + set => throw new NotSupportedException(SR.NotSupported_UnseekableStream); + } + + /// + public override int Read(byte[] buffer, int offset, int count) + { + ValidateBufferArguments(buffer, offset, count); + + return Read(new Span(buffer, offset, count)); + } + + /// + public override int Read(Span buffer) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + if (buffer.Length == 0 || _charPosition >= _text.Length) + { + return 0; + } + + ReadOnlySpan remaining = _text.Span.Slice(_charPosition); + bool flush = true; + + _encoder.Convert(remaining, buffer, flush, out int charsUsed, out int bytesUsed, out _); + _charPosition += charsUsed; + + return bytesUsed; + } + + /// + public override int ReadByte() + { + Span oneByte = stackalloc byte[1]; + int bytesRead = Read(oneByte); + + return bytesRead > 0 ? oneByte[0] : -1; + } + + /// + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + ValidateBufferArguments(buffer, offset, count); + + if (cancellationToken.IsCancellationRequested) + return Task.FromCanceled(cancellationToken); + + return Task.FromResult(Read(buffer, offset, count)); + } + + /// + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + if (cancellationToken.IsCancellationRequested) + return ValueTask.FromCanceled(cancellationToken); + + return new ValueTask(Read(buffer.Span)); + } + + /// + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(SR.NotSupported_UnseekableStream); + + /// + public override void SetLength(long value) => throw new NotSupportedException(SR.NotSupported_UnwritableStream); + + /// + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(SR.NotSupported_UnwritableStream); + + /// + public override void Write(ReadOnlySpan buffer) => throw new NotSupportedException(SR.NotSupported_UnwritableStream); + + /// + public override void Flush() { } + + /// + public override Task FlushAsync(CancellationToken cancellationToken) => + cancellationToken.IsCancellationRequested ? Task.FromCanceled(cancellationToken) : Task.CompletedTask; + + /// + protected override void Dispose(bool disposing) + { + _disposed = true; + base.Dispose(disposing); + } +} diff --git a/src/libraries/System.Private.CoreLib/src/System/IO/WritableMemoryStream.cs b/src/libraries/System.Private.CoreLib/src/System/IO/WritableMemoryStream.cs new file mode 100644 index 00000000000000..4cb9f6efbe059b --- /dev/null +++ b/src/libraries/System.Private.CoreLib/src/System/IO/WritableMemoryStream.cs @@ -0,0 +1,237 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Threading; +using System.Threading.Tasks; + +namespace System.IO; + +/// +/// Provides a seekable, writable over a with fixed capacity. +/// +/// +/// The stream cannot expand beyond the initial memory capacity. +/// This type is not thread-safe. Synchronize access if the stream is used concurrently. +/// +public sealed class WritableMemoryStream : Stream +{ + private Memory _buffer; + private int _position; + private bool _isOpen; + + /// + /// Initializes a new instance of the class over the specified . + /// + /// The to wrap. + public WritableMemoryStream(Memory buffer) + { + _buffer = buffer; + _isOpen = true; + } + + /// + public override bool CanRead => _isOpen; + + /// + public override bool CanSeek => _isOpen; + + /// + public override bool CanWrite => _isOpen; + + /// + public override long Length + { + get + { + EnsureNotClosed(); + + return _buffer.Length; + } + } + + /// + public override long Position + { + get + { + EnsureNotClosed(); + + return _position; + } + set + { + EnsureNotClosed(); + ArgumentOutOfRangeException.ThrowIfNegative(value); + ArgumentOutOfRangeException.ThrowIfGreaterThan(value, int.MaxValue); + _position = (int)value; + } + } + + /// + public override int ReadByte() + { + EnsureNotClosed(); + + if (_position >= _buffer.Length) + return -1; + + return _buffer.Span[_position++]; + } + + /// + public override int Read(byte[] buffer, int offset, int count) + { + ValidateBufferArguments(buffer, offset, count); + + return Read(new Span(buffer, offset, count)); + } + + /// + public override int Read(Span buffer) + { + EnsureNotClosed(); + + int remaining = _buffer.Length - _position; + if (remaining <= 0 || buffer.Length == 0) + return 0; + + int bytesToRead = Math.Min(remaining, buffer.Length); + ((ReadOnlyMemory)_buffer).Span.Slice(_position, bytesToRead).CopyTo(buffer); + _position += bytesToRead; + + return bytesToRead; + } + + /// + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + ValidateBufferArguments(buffer, offset, count); + + if (cancellationToken.IsCancellationRequested) + return Task.FromCanceled(cancellationToken); + + return Task.FromResult(Read(buffer, offset, count)); + } + + /// + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + if (cancellationToken.IsCancellationRequested) + return ValueTask.FromCanceled(cancellationToken); + + return new ValueTask(Read(buffer.Span)); + } + + /// + public override void WriteByte(byte value) + { + EnsureNotClosed(); + + if (_position >= _buffer.Length) + throw new NotSupportedException(SR.NotSupported_MemStreamNotExpandable); + + _buffer.Span[_position++] = value; + } + + /// + public override void Write(byte[] buffer, int offset, int count) + { + ValidateBufferArguments(buffer, offset, count); + Write(new ReadOnlySpan(buffer, offset, count)); + } + + /// + public override void Write(ReadOnlySpan buffer) + { + EnsureNotClosed(); + + if (_position > _buffer.Length - buffer.Length) + throw new NotSupportedException(SR.NotSupported_MemStreamNotExpandable); + + buffer.CopyTo(_buffer.Span.Slice(_position)); + _position += buffer.Length; + } + + /// + public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + ValidateBufferArguments(buffer, offset, count); + + if (cancellationToken.IsCancellationRequested) + return Task.FromCanceled(cancellationToken); + + try + { + Write(buffer, offset, count); + + return Task.CompletedTask; + } + catch (Exception exception) + { + return Task.FromException(exception); + } + } + + /// + public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + if (cancellationToken.IsCancellationRequested) + return ValueTask.FromCanceled(cancellationToken); + + try + { + Write(buffer.Span); + + return default; + } + catch (Exception exception) + { + return ValueTask.FromException(exception); + } + } + + /// + public override long Seek(long offset, SeekOrigin origin) + { + EnsureNotClosed(); + + long newPosition = origin switch + { + SeekOrigin.Begin => offset, + SeekOrigin.Current => _position + offset, + SeekOrigin.End => _buffer.Length + offset, + _ => throw new ArgumentException(SR.Argument_InvalidSeekOrigin) + }; + + if (newPosition < 0) + throw new IOException(SR.IO_SeekBeforeBegin); + + ArgumentOutOfRangeException.ThrowIfGreaterThan(newPosition, int.MaxValue, nameof(offset)); + + _position = (int)newPosition; + + return newPosition; + } + + /// + public override void SetLength(long value) => throw new NotSupportedException(SR.NotSupported_MemStreamNotExpandable); + + /// + public override void Flush() { } + + /// + public override Task FlushAsync(CancellationToken cancellationToken) => + cancellationToken.IsCancellationRequested ? Task.FromCanceled(cancellationToken) : Task.CompletedTask; + + /// + protected override void Dispose(bool disposing) + { + _isOpen = false; + base.Dispose(disposing); + } + + private void EnsureNotClosed() + { + ObjectDisposedException.ThrowIf(!_isOpen, this); + } +} diff --git a/src/libraries/System.Private.DataContractSerialization/src/System/Runtime/Serialization/Json/JsonXmlDataContract.cs b/src/libraries/System.Private.DataContractSerialization/src/System/Runtime/Serialization/Json/JsonXmlDataContract.cs index 94dc86ace2400d..f431d1b4119dac 100644 --- a/src/libraries/System.Private.DataContractSerialization/src/System/Runtime/Serialization/Json/JsonXmlDataContract.cs +++ b/src/libraries/System.Private.DataContractSerialization/src/System/Runtime/Serialization/Json/JsonXmlDataContract.cs @@ -30,7 +30,7 @@ public JsonXmlDataContract(XmlDataContract traditionalXmlDataContract) DataContractSerializer dataContractSerializer = new DataContractSerializer(TraditionalDataContract.UnderlyingType, GetKnownTypesFromContext(context, context?.SerializerKnownTypeList), 1, false, false); // maxItemsInObjectGraph // ignoreExtensionDataObject // preserveObjectReferences - MemoryStream memoryStream = new MemoryStream(Encoding.UTF8.GetBytes(xmlContent)); + Stream memoryStream = new StringStream(xmlContent, Encoding.UTF8); object? xmlValue; XmlDictionaryReaderQuotas? quotas = ((JsonReaderDelegator)jsonReader).ReaderQuotas; if (quotas == null) diff --git a/src/libraries/System.Private.Xml/src/System/Xml/Resolvers/XmlPreloadedResolver.cs b/src/libraries/System.Private.Xml/src/System/Xml/Resolvers/XmlPreloadedResolver.cs index 1756485ad05b12..7a834012c9ca3d 100644 --- a/src/libraries/System.Private.Xml/src/System/Xml/Resolvers/XmlPreloadedResolver.cs +++ b/src/libraries/System.Private.Xml/src/System/Xml/Resolvers/XmlPreloadedResolver.cs @@ -103,7 +103,7 @@ internal StringData(string str) internal override Stream AsStream() { - return new MemoryStream(Encoding.Unicode.GetBytes(_str)); + return new StringStream(_str, Encoding.Unicode); } internal override TextReader AsTextReader() diff --git a/src/libraries/System.Runtime/ref/System.Runtime.cs b/src/libraries/System.Runtime/ref/System.Runtime.cs index 6bb8a8766c5e88..9d287961755302 100644 --- a/src/libraries/System.Runtime/ref/System.Runtime.cs +++ b/src/libraries/System.Runtime/ref/System.Runtime.cs @@ -10897,7 +10897,7 @@ public void ReadExactly(System.Span buffer) { } public System.Threading.Tasks.ValueTask ReadExactlyAsync(System.Memory buffer, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public abstract long Seek(long offset, System.IO.SeekOrigin origin); public abstract void SetLength(long value); - public static System.IO.Stream Synchronized(System.IO.Stream stream) { throw null; } + public static Stream Synchronized(Stream stream) { throw null; } protected static void ValidateBufferArguments(byte[] buffer, int offset, int count) { } protected static void ValidateCopyToArguments(System.IO.Stream destination, int bufferSize) { } public abstract void Write(byte[] buffer, int offset, int count); @@ -11016,6 +11016,60 @@ protected override void Dispose(bool disposing) { } public override System.Threading.Tasks.Task ReadToEndAsync() { throw null; } public override System.Threading.Tasks.Task ReadToEndAsync(System.Threading.CancellationToken cancellationToken) { throw null; } } + public sealed partial class StringStream : System.IO.Stream + { + public StringStream(string text, System.Text.Encoding encoding) { } + public StringStream(System.ReadOnlyMemory text, System.Text.Encoding encoding) { } + public System.Text.Encoding Encoding { get { throw null; } } + public override bool CanRead { get { throw null; } } + public override bool CanSeek { get { throw null; } } + public override bool CanWrite { get { throw null; } } + public override long Length { get { throw null; } } + public override long Position { get { throw null; } set { } } + public override void Flush() { } + public override int Read(byte[] buffer, int offset, int count) { throw null; } + public override int Read(System.Span buffer) { throw null; } + public override int ReadByte() { throw null; } + public override long Seek(long offset, System.IO.SeekOrigin origin) { throw null; } + public override void SetLength(long value) { } + public override void Write(byte[] buffer, int offset, int count) { } + } + public sealed partial class ReadOnlyMemoryStream : System.IO.Stream + { + public ReadOnlyMemoryStream(System.ReadOnlyMemory source) { } + public override bool CanRead { get { throw null; } } + public override bool CanSeek { get { throw null; } } + public override bool CanWrite { get { throw null; } } + public override long Length { get { throw null; } } + public override long Position { get { throw null; } set { } } + public override void CopyTo(System.IO.Stream destination, int bufferSize) { } + public override System.Threading.Tasks.Task CopyToAsync(System.IO.Stream destination, int bufferSize, System.Threading.CancellationToken cancellationToken) { throw null; } + public override void Flush() { } + public override int Read(byte[] buffer, int offset, int count) { throw null; } + public override int Read(System.Span buffer) { throw null; } + public override int ReadByte() { throw null; } + public override long Seek(long offset, System.IO.SeekOrigin origin) { throw null; } + public override void SetLength(long value) { } + public override void Write(byte[] buffer, int offset, int count) { } + } + public sealed partial class WritableMemoryStream : System.IO.Stream + { + public WritableMemoryStream(System.Memory buffer) { } + public override bool CanRead { get { throw null; } } + public override bool CanSeek { get { throw null; } } + public override bool CanWrite { get { throw null; } } + public override long Length { get { throw null; } } + public override long Position { get { throw null; } set { } } + public override void Flush() { } + public override int Read(byte[] buffer, int offset, int count) { throw null; } + public override int Read(System.Span buffer) { throw null; } + public override int ReadByte() { throw null; } + public override long Seek(long offset, System.IO.SeekOrigin origin) { throw null; } + public override void SetLength(long value) { } + public override void Write(byte[] buffer, int offset, int count) { } + public override void Write(System.ReadOnlySpan buffer) { } + public override void WriteByte(byte value) { } + } public partial class StringWriter : System.IO.TextWriter { public StringWriter() { } diff --git a/src/libraries/System.Runtime/tests/System.IO.Tests/ReadOnlyMemoryStream/ReadOnlyMemoryStreamConformanceTests.cs b/src/libraries/System.Runtime/tests/System.IO.Tests/ReadOnlyMemoryStream/ReadOnlyMemoryStreamConformanceTests.cs new file mode 100644 index 00000000000000..389e10f1848d00 --- /dev/null +++ b/src/libraries/System.Runtime/tests/System.IO.Tests/ReadOnlyMemoryStream/ReadOnlyMemoryStreamConformanceTests.cs @@ -0,0 +1,39 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Threading.Tasks; + +namespace System.IO.Tests +{ + /// + /// Conformance tests for ReadOnlyMemoryStream - a read-only, seekable stream + /// over a ReadOnlyMemory<byte>. + /// + public class ReadOnlyMemoryStreamConformanceTests : StandaloneStreamConformanceTests + { + protected override bool CanSeek => true; + protected override bool CanSetLength => false; // Immutable stream + protected override bool NopFlushCompletesSynchronously => true; + + /// + /// Creates a read-only ReadOnlyMemoryStream with provided initial data. + /// + protected override Task CreateReadOnlyStreamCore(byte[]? initialData) + { + if (initialData == null || initialData.Length == 0) + { + // Empty data + return Task.FromResult(new ReadOnlyMemoryStream(ReadOnlyMemory.Empty)); + } + + var data = new ReadOnlyMemory(initialData); + return Task.FromResult(new ReadOnlyMemoryStream(data)); + } + + // Write only stream - no write support + protected override Task CreateWriteOnlyStreamCore(byte[]? initialData) => Task.FromResult(null); + + // Read only stream - no read/write support + protected override Task CreateReadWriteStreamCore(byte[]? initialData) => Task.FromResult(null); + } +} diff --git a/src/libraries/System.Runtime/tests/System.IO.Tests/ReadOnlyMemoryStream/ReadOnlyMemoryStreamTests.cs b/src/libraries/System.Runtime/tests/System.IO.Tests/ReadOnlyMemoryStream/ReadOnlyMemoryStreamTests.cs new file mode 100644 index 00000000000000..6727e800c0c76b --- /dev/null +++ b/src/libraries/System.Runtime/tests/System.IO.Tests/ReadOnlyMemoryStream/ReadOnlyMemoryStreamTests.cs @@ -0,0 +1,312 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Threading.Tasks; +using Xunit; + +namespace System.IO.Tests +{ + /// + /// Additional specific tests for ReadOnlyMemoryStream beyond conformance tests. + /// + public class ReadOnlyMemoryStreamTests + { + [Fact] + public void Constructor_DefaultParameters_CreatesPubliclyVisibleStream() + { + byte[] buffer = new byte[100]; + Stream stream = new ReadOnlyMemoryStream(new ReadOnlyMemory(buffer)); + + Assert.True(stream.CanRead); + Assert.False(stream.CanWrite); + Assert.True(stream.CanSeek); + Assert.Equal(100, stream.Length); + Assert.Equal(0, stream.Position); + } + + // Empty ReadOnlyMemory creates valid zero-length stream. + [Fact] + public void Constructor_EmptyMemory_CreatesZeroLengthStream() + { + ReadOnlyMemory emptyMemory = ReadOnlyMemory.Empty; + Stream stream = new ReadOnlyMemoryStream(emptyMemory); + + Assert.Equal(0, stream.Length); + Assert.Equal(0, stream.Position); + Assert.True(stream.CanRead); + Assert.False(stream.CanWrite); + } + + [Fact] + public void Constructor_FromMemory_WorksCorrectly() + { + byte[] buffer = { 1, 2, 3, 4, 5 }; + Memory memory = buffer; + Stream stream = new ReadOnlyMemoryStream(memory); // Implicit conversion + + Assert.Equal(5, stream.Length); + Assert.True(stream.CanRead); + } + + // Not covered in conformance tests: ReadOnlyMemory slices stream handling + [Fact] + public void Stream_WorksWithSlicedMemory() + { + byte[] largeBuffer = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 }; + ReadOnlyMemory slice = largeBuffer.AsMemory(3, 4); // [3, 4, 5, 6] + Stream stream = new ReadOnlyMemoryStream(slice); + + Assert.Equal(4, stream.Length); + + byte[] result = new byte[4]; + int bytesRead = stream.Read(result, 0, 4); + + Assert.Equal(4, bytesRead); + Assert.Equal(new byte[] { 3, 4, 5, 6 }, result); + } + + [Fact] + public void Position_AdvancesDuringRead() + { + byte[] buffer = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }; + Stream stream = new ReadOnlyMemoryStream(buffer); + byte[] readBuffer = new byte[3]; + + Assert.Equal(0, stream.Position); + + stream.Read(readBuffer, 0, 3); + Assert.Equal(3, stream.Position); + + stream.Read(readBuffer, 0, 3); + Assert.Equal(6, stream.Position); + + stream.Read(readBuffer, 0, 3); + Assert.Equal(9, stream.Position); + } + + [Fact] + public void Seek_FromCurrent_RelativeOffset() + { + Stream stream = new ReadOnlyMemoryStream(new byte[100]); + stream.Position = 50; + + // Seek forward 10 bytes + long newPosition = stream.Seek(10, SeekOrigin.Current); + Assert.Equal(60, newPosition); + + // Seek backward 20 bytes + newPosition = stream.Seek(-20, SeekOrigin.Current); + Assert.Equal(40, newPosition); + } + + [Fact] + public void Seek_InvalidOrigin_ThrowsArgumentException() + { + Stream stream = new ReadOnlyMemoryStream(new byte[100]); + + Assert.Throws(() => stream.Seek(0, (SeekOrigin)999)); + } + + [Fact] + public void Read_ReturnsCorrectData() + { + byte[] data = { 10, 20, 30, 40, 50 }; + Stream stream = new ReadOnlyMemoryStream(data); + byte[] buffer = new byte[3]; + + int bytesRead = stream.Read(buffer, 0, 3); + + Assert.Equal(3, bytesRead); + Assert.Equal(new byte[] { 10, 20, 30 }, buffer); + Assert.Equal(3, stream.Position); + } + + [Fact] + public void Read_LargerThanAvailable_ReturnsPartialData() + { + byte[] data = { 1, 2, 3 }; + Stream stream = new ReadOnlyMemoryStream(data); + byte[] buffer = new byte[10]; + + int bytesRead = stream.Read(buffer, 0, 10); + + Assert.Equal(3, bytesRead); + Assert.Equal(new byte[] { 1, 2, 3 }, buffer[..3]); + } + + [Fact] + public void Read_AfterSeek_ReturnsCorrectData() + { + byte[] data = { 10, 20, 30, 40, 50 }; + Stream stream = new ReadOnlyMemoryStream(data); + + stream.Seek(2, SeekOrigin.Begin); + byte[] buffer = new byte[2]; + int bytesRead = stream.Read(buffer, 0, 2); + + Assert.Equal(2, bytesRead); + Assert.Equal(new byte[] { 30, 40 }, buffer); + } + + [Fact] + public void Read_DoesNotModifyUnderlyingMemory() + { + byte[] originalData = { 1, 2, 3, 4, 5 }; + byte[] dataCopy = (byte[])originalData.Clone(); + Stream stream = new ReadOnlyMemoryStream(originalData); + + byte[] buffer = new byte[5]; + stream.Read(buffer, 0, 5); + + // Original data should be unchanged + Assert.Equal(dataCopy, originalData); + } + + [Fact] + public void Write_ThrowsNotSupportedException() + { + Stream stream = new ReadOnlyMemoryStream(new ReadOnlyMemory(new byte[10])); + byte[] data = { 1, 2, 3 }; + + Assert.Throws(() => stream.Write(data, 0, 3)); + } + + [Fact] + public void SetLength_ThrowsNotSupportedException() + { + Stream stream = new ReadOnlyMemoryStream(new byte[10]); + Assert.Throws(() => stream.SetLength(20)); + } + + [Fact] + public void Dispose_SetsCanPropertiesToFalse() + { + Stream stream = new ReadOnlyMemoryStream(new byte[10]); + + stream.Dispose(); + + Assert.False(stream.CanRead); + Assert.False(stream.CanSeek); + Assert.False(stream.CanWrite); + } + + [Fact] + public void Operations_AfterDispose_ThrowObjectDisposedException() + { + byte[] buffer = new byte[10]; + Stream stream = new ReadOnlyMemoryStream(buffer); + stream.Dispose(); + + Assert.Throws(() => stream.Read(new byte[5], 0, 5)); + Assert.Throws(() => stream.ReadByte()); + Assert.Throws(() => stream.Seek(0, SeekOrigin.Begin)); + Assert.Throws(() => _ = stream.Position); + Assert.Throws(() => stream.Position = 0); + Assert.Throws(() => _ = stream.Length); + } + + // Standard IDisposable pattern - Dispose() should be idempotent. + [Fact] + public void Dispose_MultipleCalls_DoesNotThrow() + { + Stream stream = new ReadOnlyMemoryStream(new byte[10]); + + stream.Dispose(); + stream.Dispose(); // Should not throw + stream.Dispose(); // Should not throw + } + + [Fact] + public void Read_NullBuffer_ThrowsArgumentNullException() + { + Stream stream = new ReadOnlyMemoryStream(new byte[10]); + + Assert.Throws(() => stream.Read(null!, 0, 5)); + } + + [Fact] + public void EmptyBuffer_BehavesCorrectly() + { + Stream stream = new ReadOnlyMemoryStream(ReadOnlyMemory.Empty); + + Assert.Equal(0, stream.Length); + Assert.Equal(0, stream.Position); + + byte[] buffer = new byte[10]; + Assert.Equal(0, stream.Read(buffer, 0, 10)); + + stream.Seek(0, SeekOrigin.Begin); + Assert.Equal(0, stream.Position); + + // Seeking beyond empty buffer is allowed + long newPosition = stream.Seek(1, SeekOrigin.Begin); + Assert.Equal(1, newPosition); + Assert.Equal(1, stream.Position); + } + + [Fact] + public async Task ReadAsync_SameResultSize_ReusesCachedTask() + { + byte[] data = new byte[20]; + for (int i = 0; i < 20; i++) data[i] = (byte)i; + Stream stream = new ReadOnlyMemoryStream(data); + + byte[] buffer1 = new byte[5]; + byte[] buffer2 = new byte[5]; + byte[] buffer3 = new byte[5]; + + Task task1 = stream.ReadAsync(buffer1, 0, 5); + Task task2 = stream.ReadAsync(buffer2, 0, 5); + Task task3 = stream.ReadAsync(buffer3, 0, 5); + + await task1; + await task2; + await task3; + + Assert.Same(task1, task2); + Assert.Same(task2, task3); + + Assert.Equal(new byte[] { 0, 1, 2, 3, 4 }, buffer1); + Assert.Equal(new byte[] { 5, 6, 7, 8, 9 }, buffer2); + Assert.Equal(new byte[] { 10, 11, 12, 13, 14 }, buffer3); + } + + [Fact] + public async Task ReadAsync_DifferentResultSize_CreatesNewTask() + { + byte[] data = new byte[10]; + for (int i = 0; i < 10; i++) data[i] = (byte)i; + Stream stream = new ReadOnlyMemoryStream(data); + + byte[] buffer1 = new byte[5]; + byte[] buffer2 = new byte[3]; + byte[] buffer3 = new byte[2]; + + Task task1 = stream.ReadAsync(buffer1, 0, 5); // Returns 5 + Task task2 = stream.ReadAsync(buffer2, 0, 3); // Returns 3 + Task task3 = stream.ReadAsync(buffer3, 0, 2); // Returns 2 + + await task1; + await task2; + await task3; + + Assert.NotSame(task1, task2); + Assert.NotSame(task2, task3); + } + + [Fact] + public async Task ReadAsync_ArrayBackedMemory_UsesFastPath() + { + byte[] data = { 10, 20, 30, 40, 50 }; + Stream stream = new ReadOnlyMemoryStream(data); + + byte[] arrayBuffer = new byte[3]; + Memory memory = arrayBuffer.AsMemory(); + + int bytesRead = await stream.ReadAsync(memory); + + Assert.Equal(3, bytesRead); + Assert.Equal(new byte[] { 10, 20, 30 }, arrayBuffer); + } + } +} diff --git a/src/libraries/System.Runtime/tests/System.IO.Tests/StringStream/StringStreamConformanceTests.cs b/src/libraries/System.Runtime/tests/System.IO.Tests/StringStream/StringStreamConformanceTests.cs new file mode 100644 index 00000000000000..45afe263b65323 --- /dev/null +++ b/src/libraries/System.Runtime/tests/System.IO.Tests/StringStream/StringStreamConformanceTests.cs @@ -0,0 +1,72 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text; +using System.Threading.Tasks; + +namespace System.IO.Tests +{ + /// + /// Conformance tests for StringStream using the ReadOnlyMemory{char} overload. + /// + public class StringStreamConformanceTests_Memory : StandaloneStreamConformanceTests + { + protected override bool CanSeek => false; + protected override bool CanSetLength => false; + protected override bool NopFlushCompletesSynchronously => true; + + protected override Task CreateReadOnlyStreamCore(byte[]? initialData) + { + if (initialData is null || initialData.Length == 0) + { + return Task.FromResult(new StringStream(ReadOnlyMemory.Empty, Encoding.UTF8)); + } + + string sourceString = Encoding.UTF8.GetString(initialData); + + byte[] reencoded = Encoding.UTF8.GetBytes(sourceString); + if (reencoded.Length != initialData.Length || !reencoded.AsSpan().SequenceEqual(initialData)) + { + return Task.FromResult(null); + } + + return Task.FromResult(new StringStream(sourceString.AsMemory(), Encoding.UTF8)); + } + + protected override Task CreateWriteOnlyStreamCore(byte[]? initialData) => Task.FromResult(null); + + protected override Task CreateReadWriteStreamCore(byte[]? initialData) => Task.FromResult(null); + } + + /// + /// Conformance tests for StringStream using the string overload. + /// + public class StringStreamConformanceTests_String : StandaloneStreamConformanceTests + { + protected override bool CanSeek => false; + protected override bool CanSetLength => false; + protected override bool NopFlushCompletesSynchronously => true; + + protected override Task CreateReadOnlyStreamCore(byte[]? initialData) + { + if (initialData is null || initialData.Length == 0) + { + return Task.FromResult(new StringStream("", Encoding.UTF8)); + } + + string sourceString = Encoding.UTF8.GetString(initialData); + + byte[] reencoded = Encoding.UTF8.GetBytes(sourceString); + if (reencoded.Length != initialData.Length || !reencoded.AsSpan().SequenceEqual(initialData)) + { + return Task.FromResult(null); + } + + return Task.FromResult(new StringStream(sourceString, Encoding.UTF8)); + } + + protected override Task CreateWriteOnlyStreamCore(byte[]? initialData) => Task.FromResult(null); + + protected override Task CreateReadWriteStreamCore(byte[]? initialData) => Task.FromResult(null); + } +} diff --git a/src/libraries/System.Runtime/tests/System.IO.Tests/StringStream/StringStreamTests_Memory.cs b/src/libraries/System.Runtime/tests/System.IO.Tests/StringStream/StringStreamTests_Memory.cs new file mode 100644 index 00000000000000..44382ef648d7ef --- /dev/null +++ b/src/libraries/System.Runtime/tests/System.IO.Tests/StringStream/StringStreamTests_Memory.cs @@ -0,0 +1,297 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text; +using System.Threading.Tasks; +using Xunit; + +namespace System.IO.Tests +{ + /// + /// Additional specific tests for StringStream with ReadOnlyMemory{char} beyond conformance tests. + /// + public class StringStreamTests_Memory + { + [Fact] + public void Constructor_DefaultEncoding_UsesUTF8() + { + var chars = "test".AsMemory(); + var stream = new StringStream(chars, Encoding.UTF8); + + Assert.True(stream.CanRead); + Assert.False(stream.CanSeek); + Assert.False(stream.CanWrite); + } + + [Fact] + public void Constructor_ExplicitEncoding_UsesSpecifiedEncoding() + { + var chars = "test".AsMemory(); + var stream = new StringStream(chars, Encoding.UTF32); + + Assert.True(stream.CanRead); + } + + [Fact] + public void Constructor_EmptyMemory_CreatesValidStream() + { + var emptyMemory = ReadOnlyMemory.Empty; + var stream = new StringStream(emptyMemory, Encoding.UTF8); + + Assert.True(stream.CanRead); + + byte[] buffer = new byte[10]; + int bytesRead = stream.Read(buffer, 0, 10); + Assert.Equal(0, bytesRead); + } + + [Theory] + [InlineData("ASCII text")] + [InlineData("Ñoño español")] + [InlineData("Emoji: 😀🎉")] + public async Task WorksWithDifferentEncodings(string input) + { + var encodings = new[] { Encoding.UTF8, Encoding.Unicode, Encoding.UTF32 }; + + foreach (var encoding in encodings) + { + byte[] expectedBytes = encoding.GetBytes(input); + var chars = input.AsMemory(); + var stream = new StringStream(chars, encoding); + + byte[] actualBytes = new byte[expectedBytes.Length * 2]; + int totalRead = 0; + int bytesRead; + + while ((bytesRead = await stream.ReadAsync(actualBytes.AsMemory(totalRead))) > 0) + { + totalRead += bytesRead; + } + + Assert.Equal(expectedBytes.Length, totalRead); + Assert.Equal(expectedBytes, actualBytes.AsSpan(0, totalRead).ToArray()); + } + } + + [Fact] + public async Task WorksWithMemorySlice() + { + string largeString = "0123456789ABCDEFGHIJ"; + var fullMemory = largeString.AsMemory(); + var slice = fullMemory.Slice(5, 10); + + byte[] expectedBytes = Encoding.UTF8.GetBytes("56789ABCDE"); + var stream = new StringStream(slice, Encoding.UTF8); + + byte[] actualBytes = new byte[expectedBytes.Length + 10]; + int totalRead = 0; + int bytesRead; + + while ((bytesRead = await stream.ReadAsync(actualBytes.AsMemory(totalRead))) > 0) + { + totalRead += bytesRead; + } + + Assert.Equal(expectedBytes.Length, totalRead); + Assert.Equal(expectedBytes, actualBytes.AsSpan(0, totalRead).ToArray()); + } + + [Fact] + public async Task WorksWithCharArray() + { + char[] charArray = { 'H', 'e', 'l', 'l', 'o' }; + var memory = new ReadOnlyMemory(charArray); + + byte[] expectedBytes = Encoding.UTF8.GetBytes("Hello"); + var stream = new StringStream(memory, Encoding.UTF8); + + byte[] actualBytes = new byte[expectedBytes.Length + 10]; + int totalRead = 0; + int bytesRead; + + while ((bytesRead = await stream.ReadAsync(actualBytes.AsMemory(totalRead))) > 0) + { + totalRead += bytesRead; + } + + Assert.Equal(expectedBytes.Length, totalRead); + Assert.Equal(expectedBytes, actualBytes.AsSpan(0, totalRead).ToArray()); + } + + [Fact] + public async Task MultipleSlicesIndependent() + { + string source = "ABCDEFGHIJKLMNOP"; + var slice1 = source.AsMemory(0, 5); + var slice2 = source.AsMemory(5, 5); + var slice3 = source.AsMemory(10, 6); + + var stream1 = new StringStream(slice1, Encoding.UTF8); + var stream2 = new StringStream(slice2, Encoding.UTF8); + var stream3 = new StringStream(slice3, Encoding.UTF8); + + byte[] result1 = new byte[10]; + byte[] result2 = new byte[10]; + byte[] result3 = new byte[10]; + + int read1 = await stream1.ReadAsync(result1); + int read2 = await stream2.ReadAsync(result2); + int read3 = await stream3.ReadAsync(result3); + + Assert.Equal("ABCDE", Encoding.UTF8.GetString(result1, 0, read1)); + Assert.Equal("FGHIJ", Encoding.UTF8.GetString(result2, 0, read2)); + Assert.Equal("KLMNOP", Encoding.UTF8.GetString(result3, 0, read3)); + } + + [Fact] + public async Task HandlesSurrogatePairs() + { + string input = "😀😁😂🤣😃😄"; + var chars = input.AsMemory(); + byte[] expectedBytes = Encoding.UTF8.GetBytes(input); + var stream = new StringStream(chars, Encoding.UTF8); + + byte[] actualBytes = new byte[expectedBytes.Length]; + int totalRead = 0; + int bytesRead; + + while ((bytesRead = await stream.ReadAsync(actualBytes.AsMemory(totalRead))) > 0) + { + totalRead += bytesRead; + } + + Assert.Equal(expectedBytes.Length, totalRead); + Assert.Equal(expectedBytes, actualBytes.AsSpan(0, totalRead).ToArray()); + } + + [Fact] + public async Task MultiByteCharactersAcrossChunkBoundary() + { + string input = new string('A', 1023) + "你"; + var chars = input.AsMemory(); + byte[] expectedBytes = Encoding.UTF8.GetBytes(input); + var stream = new StringStream(chars, Encoding.UTF8); + + byte[] actualBytes = new byte[expectedBytes.Length]; + int totalRead = 0; + int bytesRead; + + while ((bytesRead = await stream.ReadAsync(actualBytes.AsMemory(totalRead))) > 0) + { + totalRead += bytesRead; + } + + Assert.Equal(expectedBytes.Length, totalRead); + Assert.Equal(expectedBytes, actualBytes.AsSpan(0, totalRead).ToArray()); + } + + [Fact] + public void LengthThrowsNotSupportedException() + { + var chars = "test".AsMemory(); + var stream = new StringStream(chars, Encoding.UTF8); + + Assert.Throws(() => stream.Length); + } + + [Fact] + public void PositionGetThrowsNotSupportedException() + { + var chars = "test".AsMemory(); + var stream = new StringStream(chars, Encoding.UTF8); + + Assert.Throws(() => stream.Position); + } + + [Fact] + public void PositionSetThrowsNotSupportedException() + { + var chars = "test".AsMemory(); + var stream = new StringStream(chars, Encoding.UTF8); + + Assert.Throws(() => stream.Position = 0); + } + + [Fact] + public void SeekThrowsNotSupportedException() + { + var chars = "test".AsMemory(); + var stream = new StringStream(chars, Encoding.UTF8); + + Assert.Throws(() => stream.Seek(0, SeekOrigin.Begin)); + } + + [Fact] + public void WriteThrowsNotSupportedException() + { + var chars = "test".AsMemory(); + var stream = new StringStream(chars, Encoding.UTF8); + + Assert.Throws(() => stream.Write(new byte[1], 0, 1)); + } + + [Fact] + public void SetLengthThrowsNotSupportedException() + { + var chars = "test".AsMemory(); + var stream = new StringStream(chars, Encoding.UTF8); + + Assert.Throws(() => stream.SetLength(100)); + } + + [Fact] + public void CanReadFalseAfterDispose() + { + var chars = "test".AsMemory(); + var stream = new StringStream(chars, Encoding.UTF8); + + stream.Dispose(); + + Assert.False(stream.CanRead); + } + + [Fact] + public void ReadAfterDispose_ThrowsObjectDisposedException() + { + var chars = "test".AsMemory(); + var stream = new StringStream(chars, Encoding.UTF8); + stream.Dispose(); + + byte[] buffer = new byte[10]; + Assert.Throws(() => stream.Read(buffer, 0, 10)); + } + + [Fact] + public void MultipleDispose_DoesNotThrow() + { + var chars = "test".AsMemory(); + var stream = new StringStream(chars, Encoding.UTF8); + + stream.Dispose(); + stream.Dispose(); + stream.Dispose(); + } + + [Theory] + [InlineData("Hello")] + [InlineData("Unicode: 你好")] + [InlineData("Emoji: 😀")] + public async Task ProducesSameOutputAsStringOverload(string input) + { + var memoryStream = new StringStream(input.AsMemory(), Encoding.UTF8); + var stringStream = new StringStream(input, Encoding.UTF8); + + byte[] memoryResult = new byte[1000]; + byte[] stringResult = new byte[1000]; + + int memoryBytesRead = await memoryStream.ReadAsync(memoryResult); + int stringBytesRead = await stringStream.ReadAsync(stringResult); + + Assert.Equal(stringBytesRead, memoryBytesRead); + Assert.Equal( + stringResult.AsSpan(0, stringBytesRead).ToArray(), + memoryResult.AsSpan(0, memoryBytesRead).ToArray() + ); + } + } +} diff --git a/src/libraries/System.Runtime/tests/System.IO.Tests/StringStream/StringStreamTests_String.cs b/src/libraries/System.Runtime/tests/System.IO.Tests/StringStream/StringStreamTests_String.cs new file mode 100644 index 00000000000000..3ae8d1c1c34f4f --- /dev/null +++ b/src/libraries/System.Runtime/tests/System.IO.Tests/StringStream/StringStreamTests_String.cs @@ -0,0 +1,223 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text; +using System.Threading.Tasks; +using Xunit; + +namespace System.IO.Tests +{ + /// + /// Additional specific tests for StringStream with string beyond conformance tests. + /// + public class StringStreamTests_String + { + [Theory] + [InlineData("Hello, World! ")] + [InlineData("Unicode: 你好世界 🌍")] + [InlineData("Multi\nLine\r\nText")] + public async Task ReadsCorrectBytesForDifferentStrings(string input) + { + byte[] expectedBytes = Encoding.UTF8.GetBytes(input); + var stream = new StringStream(input, Encoding.UTF8); + + byte[] actualBytes = new byte[expectedBytes.Length + 100]; + int totalRead = 0; + int bytesRead; + while ((bytesRead = await stream.ReadAsync(actualBytes.AsMemory(totalRead))) > 0) + { + totalRead += bytesRead; + } + + Assert.Equal(expectedBytes.Length, totalRead); + Assert.Equal(expectedBytes, actualBytes.AsSpan(0, totalRead).ToArray()); + } + + [Theory] + [InlineData("ASCII text")] + [InlineData("Ñoño español")] + public async Task WorksWithDifferentEncodings(string input) + { + var encodings = new[] { Encoding.UTF8, Encoding.Unicode, Encoding.UTF32 }; + + foreach (var encoding in encodings) + { + byte[] expectedBytes = encoding.GetBytes(input); + var stream = new StringStream(input, encoding); + + byte[] actualBytes = new byte[expectedBytes.Length * 2]; + int totalRead = 0; + int bytesRead; + + while ((bytesRead = await stream.ReadAsync(actualBytes.AsMemory(totalRead))) > 0) + { + totalRead += bytesRead; + } + + Assert.Equal(expectedBytes.Length, totalRead); + Assert.Equal(expectedBytes, actualBytes.AsSpan(0, totalRead).ToArray()); + } + } + + [Fact] + public void ThrowsOnNullString() + { + Assert.Throws(() => new StringStream((string)null!, Encoding.UTF8)); + } + + [Fact] + public void ThrowsOnNullEncoding() + { + Assert.Throws(() => new StringStream("test", null!)); + } + + [Fact] + public void CanReadPropertyReturnsTrue() + { + var stream = new StringStream("test", Encoding.UTF8); + Assert.True(stream.CanRead); + } + + [Fact] + public void CanSeekPropertyReturnsFalse() + { + var stream = new StringStream("test", Encoding.UTF8); + Assert.False(stream.CanSeek); + } + + [Fact] + public void CanWritePropertyReturnsFalse() + { + var stream = new StringStream("test", Encoding.UTF8); + Assert.False(stream.CanWrite); + } + + [Fact] + public void EncodingPropertyReturnsCorrectEncoding() + { + var stream = new StringStream("test", Encoding.UTF32); + Assert.Equal(Encoding.UTF32, stream.Encoding); + } + + [Fact] + public void LengthThrowsNotSupportedException() + { + var stream = new StringStream("test", Encoding.UTF8); + Assert.Throws(() => stream.Length); + } + + [Fact] + public void PositionGetThrowsNotSupportedException() + { + var stream = new StringStream("test", Encoding.UTF8); + Assert.Throws(() => stream.Position); + } + + [Fact] + public void PositionSetThrowsNotSupportedException() + { + var stream = new StringStream("test", Encoding.UTF8); + Assert.Throws(() => stream.Position = 0); + } + + [Fact] + public void SeekThrowsNotSupportedException() + { + var stream = new StringStream("test", Encoding.UTF8); + Assert.Throws(() => stream.Seek(0, SeekOrigin.Begin)); + } + + [Fact] + public void WriteThrowsNotSupportedException() + { + var stream = new StringStream("test", Encoding.UTF8); + Assert.Throws(() => stream.Write(new byte[1], 0, 1)); + } + + [Fact] + public void SetLengthThrowsNotSupportedException() + { + var stream = new StringStream("test", Encoding.UTF8); + Assert.Throws(() => stream.SetLength(100)); + } + + [Fact] + public async Task HandlesChunkedReading() + { + string largeString = new string('A', 10000); + byte[] expectedBytes = Encoding.UTF8.GetBytes(largeString); + var stream = new StringStream(largeString, Encoding.UTF8); + + byte[] actualBytes = new byte[expectedBytes.Length]; + int totalRead = 0; + int chunkSize = 512; + + int bytesRead; + while ((bytesRead = await stream.ReadAsync(actualBytes.AsMemory(totalRead, Math.Min(chunkSize, expectedBytes.Length - totalRead)))) > 0) + { + totalRead += bytesRead; + } + + Assert.Equal(expectedBytes.Length, totalRead); + Assert.Equal(expectedBytes, actualBytes); + } + + [Fact] + public async Task ReadsWithExactBufferSizeMatch() + { + string input = new string('A', 4096); + byte[] expectedBytes = Encoding.UTF8.GetBytes(input); + var stream = new StringStream(input, Encoding.UTF8); + + byte[] buffer = new byte[4096]; + int bytesRead = await stream.ReadAsync(buffer); + + Assert.Equal(4096, bytesRead); + Assert.Equal(expectedBytes, buffer); + } + + [Fact] + public async Task MultipleReadsEventuallyReturnZero() + { + var stream = new StringStream("small", Encoding.UTF8); + byte[] buffer = new byte[100]; + + int bytesRead = await stream.ReadAsync(buffer); + Assert.Equal(5, bytesRead); + + int finalRead = await stream.ReadAsync(buffer); + Assert.Equal(0, finalRead); + } + + [Fact] + public async Task SequentialReadAsync_WithSmallChunks_ReadsEntireStream() + { + string input = new string('A', 5000); + byte[] expectedBytes = Encoding.UTF8.GetBytes(input); + var stream = new StringStream(input, Encoding.UTF8); + + byte[] actualBytes = new byte[expectedBytes.Length]; + int totalBytesRead = 0; + int chunkSize = 128; + + int bytesRead; + while ((bytesRead = await stream.ReadAsync(actualBytes.AsMemory(totalBytesRead, Math.Min(chunkSize, expectedBytes.Length - totalBytesRead)))) > 0) + { + totalBytesRead += bytesRead; + } + + Assert.Equal(expectedBytes.Length, totalBytesRead); + Assert.Equal(expectedBytes, actualBytes); + } + + [Fact] + public void DisposeRendersStreamUnreadable() + { + var stream = new StringStream("test", Encoding.UTF8); + stream.Dispose(); + + Assert.False(stream.CanRead); + Assert.Throws(() => stream.Read(new byte[1], 0, 1)); + } + } +} diff --git a/src/libraries/System.Runtime/tests/System.IO.Tests/System.IO.Tests.csproj b/src/libraries/System.Runtime/tests/System.IO.Tests/System.IO.Tests.csproj index ebb8cdcd4db80e..72319ed6b6912d 100644 --- a/src/libraries/System.Runtime/tests/System.IO.Tests/System.IO.Tests.csproj +++ b/src/libraries/System.Runtime/tests/System.IO.Tests/System.IO.Tests.csproj @@ -1,4 +1,4 @@ - + System.IO true @@ -33,6 +33,13 @@ + + + + + + + diff --git a/src/libraries/System.Runtime/tests/System.IO.Tests/WritableMemoryStream/WritableMemoryStreamConformanceTests.cs b/src/libraries/System.Runtime/tests/System.IO.Tests/WritableMemoryStream/WritableMemoryStreamConformanceTests.cs new file mode 100644 index 00000000000000..995191a75fad26 --- /dev/null +++ b/src/libraries/System.Runtime/tests/System.IO.Tests/WritableMemoryStream/WritableMemoryStreamConformanceTests.cs @@ -0,0 +1,72 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Threading.Tasks; +using Xunit; + +namespace System.IO.Tests +{ + public class WritableMemoryStreamConformanceTests : StandaloneStreamConformanceTests + { + protected override bool CanSeek => true; + protected override bool CanSetLength => false; + protected override bool NopFlushCompletesSynchronously => true; + // This stream can't grow beyond initial capacity + protected override bool CanSetLengthGreaterThanCapacity => false; + + protected override Task CreateReadOnlyStreamCore(byte[]? initialData) + { + if (initialData == null || initialData.Length == 0) + { + // Create empty memory for null or empty data + return Task.FromResult(new ReadOnlyMemoryStream(ReadOnlyMemory.Empty)); + } + + // Create read-only stream from ReadOnlyMemory + return Task.FromResult(new ReadOnlyMemoryStream(new ReadOnlyMemory(initialData))); + } + + protected override Task CreateWriteOnlyStreamCore(byte[]? initialData) => Task.FromResult(null); + + protected override Task CreateReadWriteStreamCore(byte[]? initialData) + { + // WritableMemoryStream wraps a fixed-capacity Memory buffer where Length == capacity. + // Unlike MemoryStream, there's no concept of "logical length" separate from capacity. + // This means WritableMemoryStream doesn't support the common pattern of creating an empty stream + // and writing to it to grow it. Many conformance tests rely on this pattern. + // + // Returning null here skips tests that require creating an initially-empty writable stream, + // as those tests fundamentally conflict with WritableMemoryStream's buffer-wrapping semantics. + if (initialData == null || initialData.Length == 0) + { + return Task.FromResult(null); + } + + var memory = new Memory(initialData); + return Task.FromResult(new WritableMemoryStream(memory)); + } + + // Note to both skipped tests: It was already verified that this works when using just WritableMemoryStream, + // before adding the 'forking' in Stream behavior for fast-path MemoryStream usage. + + // Override to skip the SetLength test for writable streams + // MemoryStream (returned by fast path) behaves differently than WritableMemoryStream + [Fact] + public override Task SetLength_FailsForWritableIfApplicable_Throws() + { + // Skip this test - MemoryStream vs WritableMemoryStream have different SetLength behavior + // MemoryStream allows SetLength, WritableMemoryStream throws NotSupportedException + return Task.CompletedTask; + } + + // Override ArgumentValidation test because MemoryStream and WritableMemoryStream + // have different SetLength behavior which affects validation + [Fact] + public override Task ArgumentValidation_ThrowsExpectedException() + { + // Skip this test - it validates SetLength which behaves differently + // between MemoryStream and WritableMemoryStream + return Task.CompletedTask; + } + } +} diff --git a/src/libraries/System.Runtime/tests/System.IO.Tests/WritableMemoryStream/WritableMemoryStreamTests.cs b/src/libraries/System.Runtime/tests/System.IO.Tests/WritableMemoryStream/WritableMemoryStreamTests.cs new file mode 100644 index 00000000000000..dd3b68475aaf3c --- /dev/null +++ b/src/libraries/System.Runtime/tests/System.IO.Tests/WritableMemoryStream/WritableMemoryStreamTests.cs @@ -0,0 +1,323 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Threading.Tasks; +using Xunit; + +namespace System.IO.Tests +{ + /// + /// Additional specific tests for WritableMemoryStream beyond conformance tests. + /// + public class WritableMemoryStreamTests + { + [Fact] + public void Constructor_EmptyMemory_CreatesZeroCapacityStream() + { + Memory emptyMemory = Memory.Empty; + Stream stream = new WritableMemoryStream(emptyMemory); + + Assert.Equal(0, stream.Length); + Assert.Equal(0, stream.Position); + + // Cannot write to zero-capacity stream + Assert.Throws(() => stream.WriteByte(42)); + } + + [Fact] + public void Write_BeyondCapacity_ThrowsNotSupportedException() + { + byte[] buffer = new byte[10]; + Stream stream = new WritableMemoryStream(new Memory(buffer)); + + byte[] data = new byte[15]; // More than capacity + + // Both MemoryStream (fixed capacity) and WritableMemoryStream throw NotSupportedException + // when trying to expand beyond capacity, just with different messages + var exception = Assert.Throws(() => + stream.Write(data, 0, data.Length)); + + // Accept either message format: WritableMemoryStream's or MemoryStream's 'SR.NotSupported_MemStreamNotExpandable' message + Assert.True( + exception.Message.Contains("Cannot expand buffer") || + exception.Message.Contains("not expandable"), + $"Unexpected exception message: {exception.Message}"); + } + + [Fact] + public void WriteByte_BeyondCapacity_ThrowsNotSupportedException() + { + byte[] buffer = new byte[3]; + Stream stream = new WritableMemoryStream(new Memory(buffer)); + + stream.WriteByte(1); + stream.WriteByte(2); + stream.WriteByte(3); + + // Both MemoryStream (fixed capacity) and WritableMemoryStream throw NotSupportedException + var exception = Assert.Throws(() => stream.WriteByte(4)); + + // Accept either message format: WritableMemoryStream's or MemoryStream's 'SR.NotSupported_MemStreamNotExpandable' message + Assert.True( + exception.Message.Contains("Cannot expand buffer") || + exception.Message.Contains("not expandable"), + $"Unexpected exception message: {exception.Message}"); + } + + [Fact] + public void Write_UpToExactCapacity_Succeeds() + { + byte[] buffer = new byte[10]; + Stream stream = new WritableMemoryStream(new Memory(buffer)); + + byte[] data = new byte[10]; // Exactly capacity + for (int i = 0; i < data.Length; i++) data[i] = (byte)i; + + stream.Write(data, 0, data.Length); + + Assert.Equal(10, stream.Position); + Assert.Equal(10, stream.Length); + + // Verify data was written + stream.Position = 0; + byte[] readBack = new byte[10]; + int bytesRead = stream.Read(readBack, 0, 10); + Assert.Equal(10, bytesRead); + Assert.Equal(data, readBack); + } + + [Fact] + public void Write_PartialFitAtEndOfCapacity_WritesAvailableSpace() + { + byte[] buffer = new byte[10]; + Stream stream = new WritableMemoryStream(buffer); + + stream.Write(new byte[8], 0, 8); // 8 bytes used, 2 remaining + Assert.Equal(8, stream.Position); + + // Try to write 5 bytes (only 2 fit) + byte[] data = new byte[5]; + Assert.Throws(() => stream.Write(data, 0, 5)); + + // Position should be unchanged after failed write + Assert.Equal(8, stream.Position); + } + + // Seeking beyond capacity is allowed. + // Write will fail, but seek succeeds. + [Fact] + public void Seek_PastCapacity_Succeeds() + { + byte[] buffer = new byte[10]; + Stream stream = new WritableMemoryStream(buffer); + + // Seek beyond capacity + stream.Seek(100, SeekOrigin.Begin); + Assert.Equal(100, stream.Position); + + Assert.Equal(-1, stream.ReadByte()); + + // Write throws (beyond capacity) + Assert.Throws(() => stream.WriteByte(42)); + } + + [Fact] + public void Seek_FromEndNegativeOffset_PositionsCorrectly() + { + byte[] buffer = new byte[100]; + Stream stream = new WritableMemoryStream(buffer); + + // Seek to 10 bytes before end + long newPosition = stream.Seek(-10, SeekOrigin.End); + + Assert.Equal(90, newPosition); // 100 - 10 = 90 + Assert.Equal(90, stream.Position); + } + + [Fact] + public void ReadOnlyStream_WriteOperations_ThrowNotSupportedException() + { + byte[] buffer = new byte[100]; + Stream stream = new ReadOnlyMemoryStream(buffer); + + Assert.False(stream.CanWrite); + Assert.Throws(() => stream.Write(new byte[5], 0, 5)); + Assert.Throws(() => stream.WriteByte(42)); + } + + [Fact] + public void Write_OverExistingData_ReplacesData() + { + byte[] buffer = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }; + Stream stream = new WritableMemoryStream(new Memory(buffer)); + + // Overwrite positions 3-5 with new data + stream.Position = 3; + stream.Write(new byte[] { 100, 101, 102 }, 0, 3); + + // Verify overwrite + stream.Position = 0; + byte[] result = new byte[10]; + stream.Read(result, 0, 10); + + Assert.Equal(new byte[] { 1, 2, 3, 100, 101, 102, 7, 8, 9, 10 }, result); + } + + [Fact] + public void Position_SetToIntMaxValue_Succeeds() + { + byte[] buffer = new byte[100]; + Stream stream = new WritableMemoryStream(buffer); + + // MemoryStream has MaxStreamLength (2147483591), WritableMemoryStream allows int.MaxValue + if (stream is MemoryStream) + { + // MemoryStream.MaxStreamLength = Array.MaxLength = 2147483591 + // Setting position beyond this throws ArgumentOutOfRangeException + Assert.Throws(() => stream.Position = int.MaxValue); + } + else + { + // WritableMemoryStream should not throw even though it's way beyond capacity + stream.Position = int.MaxValue; + Assert.Equal(int.MaxValue, stream.Position); + } + } + + [Fact] + public void Position_SetNegative_ThrowsArgumentOutOfRangeException() + { + Stream stream = new WritableMemoryStream(new byte[100]); + Assert.Throws(() => stream.Position = -1); + } + + [Fact] + public void Position_SetBeyondLongMaxValue_ThrowsArgumentOutOfRangeException() + { + Stream stream = new WritableMemoryStream(new byte[100]); + + // Position property accepts long, but internally casts to int + // Setting to value > int.MaxValue should throw + Assert.Throws(() => stream.Position = (long)int.MaxValue + 1); + } + + [Fact] + public void Dispose_SetsCanPropertiesToFalse() + { + Stream stream = new WritableMemoryStream(new byte[10]); + + stream.Dispose(); + + Assert.False(stream.CanRead); + Assert.False(stream.CanSeek); + Assert.False(stream.CanWrite); + } + + [Fact] + public void Operations_AfterDispose_ThrowObjectDisposedException() + { + byte[] buffer = new byte[10]; + Stream stream = new WritableMemoryStream(buffer); + stream.Dispose(); + + Assert.Throws(() => stream.Read(new byte[5], 0, 5)); + Assert.Throws(() => stream.Write(new byte[5], 0, 5)); + Assert.Throws(() => stream.Seek(0, SeekOrigin.Begin)); + Assert.Throws(() => _ = stream.Position); + Assert.Throws(() => stream.Position = 0); + Assert.Throws(() => _ = stream.Length); + } + + // Zero-byte write doesn't throw and leaves state unchanged. + [Fact] + public void Write_ZeroBytes_Succeeds() + { + Stream stream = new WritableMemoryStream(new byte[10]); + + stream.Write(new byte[0], 0, 0); + + Assert.Equal(0, stream.Position); + Assert.Equal(10, stream.Length); // Length from initial buffer + } + + [Fact] + public void Read_ZeroBytes_ReturnsZero() + { + Stream stream = new WritableMemoryStream(new byte[10]); + + int bytesRead = stream.Read(new byte[10], 0, 0); + + Assert.Equal(0, bytesRead); + Assert.Equal(0, stream.Position); + } + + [Fact] + public void SetLength_ThrowsNotSupportedException() + { + Stream stream = new WritableMemoryStream(new byte[10]); + + Assert.Throws(() => stream.SetLength(20)); + } + + [Fact] + public async Task ReadAsync_SameResultSize_ReusesCachedTask() + { + byte[] data = new byte[20]; + for (int i = 0; i < 20; i++) data[i] = (byte)i; + Stream stream = new WritableMemoryStream(data); + + byte[] buffer1 = new byte[5]; + byte[] buffer2 = new byte[5]; + byte[] buffer3 = new byte[5]; + + Task task1 = stream.ReadAsync(buffer1, 0, 5); + Task task2 = stream.ReadAsync(buffer2, 0, 5); + Task task3 = stream.ReadAsync(buffer3, 0, 5); + + await task1; + await task2; + await task3; + + Assert.Equal(new byte[] { 0, 1, 2, 3, 4 }, buffer1); + Assert.Equal(new byte[] { 5, 6, 7, 8, 9 }, buffer2); + Assert.Equal(new byte[] { 10, 11, 12, 13, 14 }, buffer3); + } + + [Fact] + public async Task ReadAsync_DifferentResultSize_CreatesNewTask() + { + byte[] data = new byte[10]; + for (int i = 0; i < 10; i++) data[i] = (byte)i; + Stream stream = new WritableMemoryStream(data); + + byte[] buffer1 = new byte[5]; + byte[] buffer2 = new byte[3]; + byte[] buffer3 = new byte[2]; + + Task task1 = stream.ReadAsync(buffer1, 0, 5); + Task task2 = stream.ReadAsync(buffer2, 0, 3); + Task task3 = stream.ReadAsync(buffer3, 0, 2); + + await task1; + await task2; + await task3; + + Assert.NotSame(task1, task2); + Assert.NotSame(task2, task3); + } + + [Fact] + public async Task ReadAsync_ArrayBackedMemory_UsesFastPath() + { + byte[] data = { 10, 20, 30, 40, 50 }; + Stream stream = new WritableMemoryStream(data); + + byte[] arrayBuffer = new byte[3]; + Memory memory = arrayBuffer.AsMemory(); + int bytesRead = await stream.ReadAsync(memory); + + Assert.Equal(3, bytesRead); + Assert.Equal(new byte[] { 10, 20, 30 }, arrayBuffer); + } + } +}