Skip to content

Commit e479ffc

Browse files
authored
Align stream wrappers with the dotnet 11 implementation (follow up to #562) (#563)
* Align stream wrappers with the dotnet 11 implementation (follow-up to #562) StringStream and ReadOnlySequenceStream diverged from dotnet/runtime#126669 in ways that hurt performance on the cases those types exist for. Bring them in line and port the PR's tests. StringStream - Encode incrementally via a stateful Encoder (fast-path single-shot, spillover buffer for sub-scalar reads, final flush) instead of encoding the whole text into one buffer on first read. Peak memory is now bounded by the caller's read buffer rather than the full encoded length, matching the BCL. - Span-based Encoder.Convert/Encoding.GetBytes on net core 2.1 / netstandard 2.1+, array-based equivalents on older TFMs. ReadOnlySequenceStream - Track a SequencePosition cursor alongside the absolute position so Read/ReadByte advance from the current segment instead of re-slicing from the start every call (was O(segments^2) for multi-segment sequences). Seek/Position reposition the cursor forward from the current spot, walking from the start only for back jumps. - Override CopyTo/CopyToAsync to write segments directly to the destination. CopyTo is gated to net core 2.1 / netstandard 2.1+ (Stream.CopyTo(Stream, int) is not virtual on older TFMs); CopyToAsync applies everywhere. ReadOnlyMemoryStream / WritableMemoryStream are unchanged: they are already at parity for array-backed memory, and matching the BCL for native (non-array) memory would require MemoryStream internals unavailable outside CoreLib. Tests - Port the explicit unit tests from the PR (StringStream encodings, surrogate pairs, chunk boundaries, GetMaxByteCount overflow guard, memory-slice/char-array ctors; WritableMemoryStream capacity/seek/overwrite cases) into the TUnit suite, plus ReadOnlySequenceStream CopyTo/CopyToAsync coverage. Regenerated Split + assemblySize; public API surface unchanged. Verified across all 22 TFMs (Consume) and passing on net10.0 and net48. * Update Directory.Build.props
1 parent 234e9c4 commit e479ffc

52 files changed

Lines changed: 5315 additions & 1034 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

assemblySize.include.md

Lines changed: 42 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -2,51 +2,51 @@
22

33
| | Empty Assembly | With Polyfill | Diff | Ensure | ArgumentExceptions | StringInterpolation | Nullability |
44
|----------------|----------------|---------------|-----------|-----------|--------------------|---------------------|-------------|
5-
| netstandard2.0 | 8.0KB | 347.5KB | +339.5KB | +9.0KB | +6.5KB | +9.0KB | +14.0KB |
6-
| netstandard2.1 | 8.5KB | 302.0KB | +293.5KB | +9.0KB | +6.5KB | +9.0KB | +14.0KB |
7-
| net461 | 8.5KB | 346.5KB | +338.0KB | +9.0KB | +6.5KB | +9.0KB | +13.5KB |
8-
| net462 | 7.0KB | 350.0KB | +343.0KB | +9.0KB | +6.5KB | +9.0KB | +13.5KB |
9-
| net47 | 7.0KB | 349.5KB | +342.5KB | +9.0KB | +6.5KB | +9.5KB | +14.0KB |
10-
| net471 | 8.5KB | 349.0KB | +340.5KB | +9.0KB | +6.0KB | +9.0KB | +13.5KB |
11-
| net472 | 8.5KB | 347.5KB | +339.0KB | +9.0KB | +6.5KB | +9.0KB | +13.5KB |
12-
| net48 | 8.5KB | 347.5KB | +339.0KB | +9.0KB | +6.5KB | +9.0KB | +13.5KB |
13-
| net481 | 8.5KB | 347.5KB | +339.0KB | +9.0KB | +6.5KB | +9.0KB | +13.5KB |
14-
| netcoreapp2.0 | 9.0KB | 323.5KB | +314.5KB | +9.0KB | +6.5KB | +9.0KB | +13.5KB |
15-
| netcoreapp2.1 | 9.0KB | 304.5KB | +295.5KB | +9.0KB | +6.5KB | +9.0KB | +13.5KB |
16-
| netcoreapp2.2 | 9.0KB | 304.5KB | +295.5KB | +9.0KB | +6.5KB | +9.0KB | +13.5KB |
17-
| netcoreapp3.0 | 9.5KB | 297.0KB | +287.5KB | +9.0KB | +6.5KB | +9.0KB | +14.0KB |
18-
| netcoreapp3.1 | 9.5KB | 295.5KB | +286.0KB | +9.0KB | +6.5KB | +9.0KB | +13.5KB |
19-
| net5.0 | 9.5KB | 259.0KB | +249.5KB | +9.0KB | +6.5KB | +9.5KB | +14.0KB |
20-
| net6.0 | 10.0KB | 201.0KB | +191.0KB | +10.0KB | +6.5KB | +512bytes | +3.5KB |
21-
| net7.0 | 10.0KB | 163.5KB | +153.5KB | +9.0KB | +5.5KB | +512bytes | +3.0KB |
22-
| net8.0 | 9.5KB | 135.0KB | +125.5KB | +8.0KB | | +512bytes | +3.0KB |
23-
| net9.0 | 9.5KB | 88.5KB | +79.0KB | +8.5KB | | +512bytes | +3.5KB |
24-
| net10.0 | 10.0KB | 66.0KB | +56.0KB | +9.0KB | | +512bytes | +3.5KB |
25-
| net11.0 | 10.0KB | 27.0KB | +17.0KB | +9.0KB | | +1.0KB | +3.5KB |
5+
| netstandard2.0 | 8.0KB | 351.5KB | +343.5KB | +9.0KB | +6.5KB | +9.0KB | +13.5KB |
6+
| netstandard2.1 | 8.5KB | 306.0KB | +297.5KB | +8.5KB | +6.0KB | +9.0KB | +13.5KB |
7+
| net461 | 8.5KB | 350.0KB | +341.5KB | +9.0KB | +6.5KB | +9.5KB | +14.0KB |
8+
| net462 | 7.0KB | 353.5KB | +346.5KB | +9.0KB | +6.5KB | +9.5KB | +13.5KB |
9+
| net47 | 7.0KB | 353.5KB | +346.5KB | +9.0KB | +6.5KB | +9.0KB | +13.5KB |
10+
| net471 | 8.5KB | 352.5KB | +344.0KB | +9.0KB | +6.5KB | +9.5KB | +14.0KB |
11+
| net472 | 8.5KB | 351.5KB | +343.0KB | +9.0KB | +6.0KB | +9.0KB | +13.5KB |
12+
| net48 | 8.5KB | 351.5KB | +343.0KB | +9.0KB | +6.0KB | +9.0KB | +13.5KB |
13+
| net481 | 8.5KB | 351.5KB | +343.0KB | +9.0KB | +6.0KB | +9.0KB | +13.5KB |
14+
| netcoreapp2.0 | 9.0KB | 327.5KB | +318.5KB | +9.0KB | +6.5KB | +9.0KB | +13.5KB |
15+
| netcoreapp2.1 | 9.0KB | 308.0KB | +299.0KB | +9.0KB | +6.5KB | +9.0KB | +14.0KB |
16+
| netcoreapp2.2 | 9.0KB | 308.0KB | +299.0KB | +9.0KB | +6.5KB | +9.0KB | +14.0KB |
17+
| netcoreapp3.0 | 9.5KB | 301.0KB | +291.5KB | +9.0KB | +6.5KB | +9.0KB | +13.5KB |
18+
| netcoreapp3.1 | 9.5KB | 299.0KB | +289.5KB | +9.0KB | +6.5KB | +9.0KB | +14.0KB |
19+
| net5.0 | 9.5KB | 263.0KB | +253.5KB | +9.0KB | +6.5KB | +9.0KB | +14.0KB |
20+
| net6.0 | 10.0KB | 205.0KB | +195.0KB | +9.5KB | +6.5KB | +512bytes | +3.0KB |
21+
| net7.0 | 10.0KB | 167.0KB | +157.0KB | +9.5KB | +5.5KB | +1.0KB | +3.5KB |
22+
| net8.0 | 9.5KB | 138.5KB | +129.0KB | +8.5KB | +512bytes | +512bytes | +3.5KB |
23+
| net9.0 | 9.5KB | 92.5KB | +83.0KB | +8.5KB | | +512bytes | +3.5KB |
24+
| net10.0 | 10.0KB | 70.0KB | +60.0KB | +9.0KB | | +512bytes | +3.5KB |
25+
| net11.0 | 10.0KB | 31.5KB | +21.5KB | +9.0KB | | +512bytes | +3.5KB |
2626

2727

2828
### Assembly Sizes with EmbedUntrackedSources
2929

3030
| | Empty Assembly | With Polyfill | Diff | Ensure | ArgumentExceptions | StringInterpolation | Nullability |
3131
|----------------|----------------|---------------|-----------|-----------|--------------------|---------------------|-------------|
32-
| netstandard2.0 | 8.0KB | 507.8KB | +499.8KB | +16.7KB | +8.2KB | +13.9KB | +19.4KB |
33-
| netstandard2.1 | 8.5KB | 436.6KB | +428.1KB | +16.7KB | +8.2KB | +13.9KB | +19.4KB |
34-
| net461 | 8.5KB | 507.8KB | +499.3KB | +16.7KB | +8.2KB | +13.9KB | +18.9KB |
35-
| net462 | 7.0KB | 511.3KB | +504.3KB | +16.7KB | +8.2KB | +13.9KB | +18.9KB |
36-
| net47 | 7.0KB | 510.6KB | +503.6KB | +16.7KB | +8.2KB | +14.4KB | +19.4KB |
37-
| net471 | 8.5KB | 509.7KB | +501.2KB | +16.7KB | +7.7KB | +13.9KB | +18.9KB |
38-
| net472 | 8.5KB | 507.2KB | +498.7KB | +16.7KB | +8.2KB | +13.9KB | +18.9KB |
39-
| net48 | 8.5KB | 507.2KB | +498.7KB | +16.7KB | +8.2KB | +13.9KB | +18.9KB |
40-
| net481 | 8.5KB | 507.2KB | +498.7KB | +16.7KB | +8.2KB | +13.9KB | +18.9KB |
41-
| netcoreapp2.0 | 9.0KB | 473.2KB | +464.2KB | +16.7KB | +8.2KB | +13.9KB | +18.9KB |
42-
| netcoreapp2.1 | 9.0KB | 442.8KB | +433.8KB | +16.7KB | +8.2KB | +13.9KB | +18.9KB |
43-
| netcoreapp2.2 | 9.0KB | 442.8KB | +433.8KB | +16.7KB | +8.2KB | +13.9KB | +18.9KB |
44-
| netcoreapp3.0 | 9.5KB | 426.5KB | +417.0KB | +16.7KB | +8.2KB | +13.9KB | +19.4KB |
45-
| netcoreapp3.1 | 9.5KB | 425.0KB | +415.5KB | +16.7KB | +8.2KB | +13.9KB | +18.9KB |
46-
| net5.0 | 9.5KB | 370.3KB | +360.8KB | +16.7KB | +8.2KB | +14.4KB | +19.4KB |
47-
| net6.0 | 10.0KB | 292.3KB | +282.3KB | +17.7KB | +8.2KB | +1.1KB | +4.2KB |
48-
| net7.0 | 10.0KB | 236.2KB | +226.2KB | +16.6KB | +6.9KB | +1.1KB | +3.7KB |
49-
| net8.0 | 9.5KB | 192.7KB | +183.2KB | +15.5KB | +299bytes | +1.1KB | +3.7KB |
50-
| net9.0 | 9.5KB | 125.2KB | +115.7KB | +16.0KB | | +1.1KB | +4.2KB |
51-
| net10.0 | 10.0KB | 94.4KB | +84.4KB | +16.5KB | | +1.1KB | +4.2KB |
52-
| net11.0 | 10.0KB | 41.3KB | +31.3KB | +16.5KB | | +1.6KB | +4.2KB |
32+
| netstandard2.0 | 8.0KB | 513.0KB | +505.0KB | +16.7KB | +8.2KB | +13.9KB | +18.9KB |
33+
| netstandard2.1 | 8.5KB | 441.7KB | +433.2KB | +16.2KB | +7.7KB | +13.9KB | +18.9KB |
34+
| net461 | 8.5KB | 512.5KB | +504.0KB | +16.7KB | +8.2KB | +14.4KB | +19.4KB |
35+
| net462 | 7.0KB | 516.0KB | +509.0KB | +16.7KB | +8.2KB | +14.4KB | +18.9KB |
36+
| net47 | 7.0KB | 515.8KB | +508.8KB | +16.7KB | +8.2KB | +13.9KB | +18.9KB |
37+
| net471 | 8.5KB | 514.4KB | +505.9KB | +16.7KB | +8.2KB | +14.4KB | +19.4KB |
38+
| net472 | 8.5KB | 512.4KB | +503.9KB | +16.7KB | +7.7KB | +13.9KB | +18.9KB |
39+
| net48 | 8.5KB | 512.4KB | +503.9KB | +16.7KB | +7.7KB | +13.9KB | +18.9KB |
40+
| net481 | 8.5KB | 512.4KB | +503.9KB | +16.7KB | +7.7KB | +13.9KB | +18.9KB |
41+
| netcoreapp2.0 | 9.0KB | 478.4KB | +469.4KB | +16.7KB | +8.2KB | +13.9KB | +18.9KB |
42+
| netcoreapp2.1 | 9.0KB | 447.4KB | +438.4KB | +16.7KB | +8.2KB | +13.9KB | +19.4KB |
43+
| netcoreapp2.2 | 9.0KB | 447.4KB | +438.4KB | +16.7KB | +8.2KB | +13.9KB | +19.4KB |
44+
| netcoreapp3.0 | 9.5KB | 431.6KB | +422.1KB | +16.7KB | +8.2KB | +13.9KB | +18.9KB |
45+
| netcoreapp3.1 | 9.5KB | 429.6KB | +420.1KB | +16.7KB | +8.2KB | +13.9KB | +19.4KB |
46+
| net5.0 | 9.5KB | 375.4KB | +365.9KB | +16.7KB | +8.2KB | +13.9KB | +19.4KB |
47+
| net6.0 | 10.0KB | 297.4KB | +287.4KB | +17.2KB | +8.2KB | +1.1KB | +3.7KB |
48+
| net7.0 | 10.0KB | 240.8KB | +230.8KB | +17.1KB | +6.9KB | +1.6KB | +4.2KB |
49+
| net8.0 | 9.5KB | 197.3KB | +187.8KB | +16.0KB | +811bytes | +1.1KB | +4.2KB |
50+
| net9.0 | 9.5KB | 130.3KB | +120.8KB | +16.0KB | | +1.1KB | +4.2KB |
51+
| net10.0 | 10.0KB | 99.5KB | +89.5KB | +16.5KB | | +1.1KB | +4.2KB |
52+
| net11.0 | 10.0KB | 46.9KB | +36.9KB | +16.5KB | | +1.1KB | +4.2KB |

src/Directory.Build.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
<Project>
33
<PropertyGroup>
44
<NoWarn>CS1591;NETSDK1138;NU1901;NU1902;NU1903;CA1822;CA1847;CA1861;NU1510;NU1608;NU1109</NoWarn>
5-
<Version>10.11.0</Version>
5+
<Version>10.11.1</Version>
66
<AssemblyVersion>1.0.0</AssemblyVersion>
77
<PackageTags>Polyfill</PackageTags>
88
<DisableImplicitNamespaceImports>true</DisableImplicitNamespaceImports>

src/Polyfill/ReadOnlySequenceStream.cs

Lines changed: 149 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ namespace System.Buffers;
2323
using System.IO;
2424
using System.Diagnostics;
2525
using System.Diagnostics.CodeAnalysis;
26+
using System.Runtime.InteropServices;
2627
using System.Threading;
2728
using System.Threading.Tasks;
2829

@@ -46,15 +47,22 @@ sealed class ReadOnlySequenceStream :
4647
Stream
4748
{
4849
ReadOnlySequence<byte> sequence;
50+
// Incremental cursor into the sequence's segments, kept in sync with the absolute position.
51+
// Advancing from this cursor avoids re-walking the segment list from the start on every read.
52+
SequencePosition cursor;
4953
long position;
5054
bool disposed;
5155

5256
/// <summary>
5357
/// Initializes a new instance of the <see cref="ReadOnlySequenceStream"/> class over the specified <see cref="ReadOnlySequence{Byte}"/>.
5458
/// </summary>
5559
//Link: https://learn.microsoft.com/en-us/dotnet/api/system.buffers.readonlysequencestream.-ctor?view=net-11.0
56-
public ReadOnlySequenceStream(ReadOnlySequence<byte> source) =>
60+
public ReadOnlySequenceStream(ReadOnlySequence<byte> source)
61+
{
5762
sequence = source;
63+
cursor = source.Start;
64+
position = 0;
65+
}
5866

5967
/// <inheritdoc/>
6068
public override bool CanRead => !disposed;
@@ -91,7 +99,7 @@ public override long Position
9199
throw new ArgumentOutOfRangeException(nameof(value));
92100
}
93101

94-
position = value;
102+
MoveTo(value);
95103
}
96104
}
97105

@@ -106,14 +114,15 @@ public override int Read(byte[] buffer, int offset, int count)
106114
return 0;
107115
}
108116

109-
var remaining = sequence.Slice(position);
117+
var remaining = sequence.Slice(cursor);
110118
var toRead = (int)Math.Min(remaining.Length, count);
111119
if (toRead <= 0)
112120
{
113121
return 0;
114122
}
115123

116124
remaining.Slice(0, toRead).CopyTo(buffer.AsSpan(offset, toRead));
125+
cursor = sequence.GetPosition(toRead, cursor);
117126
position += toRead;
118127
return toRead;
119128
}
@@ -128,7 +137,8 @@ public override int ReadByte()
128137
return -1;
129138
}
130139

131-
var result = sequence.Slice(position, 1).First.Span[0];
140+
var result = sequence.Slice(cursor, 1).First.Span[0];
141+
cursor = sequence.GetPosition(1, cursor);
132142
position++;
133143
return result;
134144
}
@@ -137,20 +147,14 @@ public override int ReadByte()
137147
public override Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
138148
{
139149
GuardRange(buffer, offset, count);
150+
ThrowIfDisposed();
140151

141152
if (cancellationToken.IsCancellationRequested)
142153
{
143154
return Task.FromCanceled<int>(cancellationToken);
144155
}
145156

146-
try
147-
{
148-
return Task.FromResult(Read(buffer, offset, count));
149-
}
150-
catch (Exception exception)
151-
{
152-
return Task.FromException<int>(exception);
153-
}
157+
return Task.FromResult(Read(buffer, offset, count));
154158
}
155159

156160
/// <inheritdoc/>
@@ -166,16 +170,147 @@ public override long Seek(long offset, SeekOrigin origin)
166170
_ => throw new ArgumentException("Invalid seek origin.", nameof(origin))
167171
};
168172

173+
if (offset > long.MaxValue - basePosition)
174+
{
175+
throw new ArgumentOutOfRangeException(nameof(offset));
176+
}
177+
169178
var newPosition = basePosition + offset;
170179
if (newPosition < 0)
171180
{
172181
throw new IOException("An attempt was made to move the position before the beginning of the stream.");
173182
}
174183

175-
position = newPosition;
184+
MoveTo(newPosition);
176185
return position;
177186
}
178187

188+
// Repositions the segment cursor to the given absolute position, advancing forward from the
189+
// current cursor when possible and only walking from the start for backward jumps.
190+
void MoveTo(long value)
191+
{
192+
if (value >= sequence.Length)
193+
{
194+
cursor = sequence.End;
195+
}
196+
else if (value >= position)
197+
{
198+
cursor = sequence.GetPosition(value - position, cursor);
199+
}
200+
else
201+
{
202+
cursor = sequence.GetPosition(value, sequence.Start);
203+
}
204+
205+
position = value;
206+
}
207+
208+
#if NETCOREAPP2_1_OR_GREATER || NETSTANDARD2_1_OR_GREATER
209+
// Stream.CopyTo(Stream, int) only became virtual in netcoreapp2.1/netstandard2.1. On older
210+
// targets it cannot be overridden, so the base implementation (which routes through the
211+
// cursor-based Read above) is used instead.
212+
/// <inheritdoc/>
213+
public override void CopyTo(Stream destination, int bufferSize)
214+
{
215+
GuardCopyTo(destination, bufferSize);
216+
ThrowIfDisposed();
217+
218+
if (position >= sequence.Length)
219+
{
220+
return;
221+
}
222+
223+
foreach (var segment in sequence.Slice(cursor))
224+
{
225+
destination.Write(segment.Span);
226+
}
227+
228+
cursor = sequence.End;
229+
position = sequence.Length;
230+
}
231+
#endif
232+
233+
/// <inheritdoc/>
234+
public override Task CopyToAsync(Stream destination, int bufferSize, CancellationToken cancellationToken)
235+
{
236+
GuardCopyTo(destination, bufferSize);
237+
ThrowIfDisposed();
238+
239+
if (cancellationToken.IsCancellationRequested)
240+
{
241+
return Task.FromCanceled(cancellationToken);
242+
}
243+
244+
if (position >= sequence.Length)
245+
{
246+
return Task.CompletedTask;
247+
}
248+
249+
return CopyToAsyncCore(destination, cancellationToken);
250+
}
251+
252+
async Task CopyToAsyncCore(Stream destination, CancellationToken cancellationToken)
253+
{
254+
foreach (var segment in sequence.Slice(cursor))
255+
{
256+
await WriteSegmentAsync(destination, segment, cancellationToken).ConfigureAwait(false);
257+
}
258+
259+
cursor = sequence.End;
260+
position = sequence.Length;
261+
}
262+
263+
static Task WriteSegmentAsync(Stream destination, ReadOnlyMemory<byte> segment, CancellationToken cancellationToken)
264+
{
265+
#if NETCOREAPP2_1_OR_GREATER || NETSTANDARD2_1_OR_GREATER
266+
return destination.WriteAsync(segment, cancellationToken).AsTask();
267+
#else
268+
var array = GetSegmentArray(segment, out var offset, out var count);
269+
return destination.WriteAsync(array, offset, count, cancellationToken);
270+
#endif
271+
}
272+
273+
#if !(NETCOREAPP2_1_OR_GREATER || NETSTANDARD2_1_OR_GREATER)
274+
static byte[] GetSegmentArray(ReadOnlyMemory<byte> segment, out int offset, out int count)
275+
{
276+
if (MemoryMarshal.TryGetArray(segment, out var arraySegment) &&
277+
arraySegment.Array != null)
278+
{
279+
offset = arraySegment.Offset;
280+
count = arraySegment.Count;
281+
return arraySegment.Array;
282+
}
283+
284+
var array = segment.ToArray();
285+
offset = 0;
286+
count = array.Length;
287+
return array;
288+
}
289+
#endif
290+
291+
static void GuardCopyTo(Stream destination, int bufferSize)
292+
{
293+
if (destination == null)
294+
{
295+
throw new ArgumentNullException(nameof(destination));
296+
}
297+
298+
if (bufferSize <= 0)
299+
{
300+
throw new ArgumentOutOfRangeException(nameof(bufferSize));
301+
}
302+
303+
if (!destination.CanWrite)
304+
{
305+
if (destination.CanRead)
306+
{
307+
throw new NotSupportedException("Stream does not support writing.");
308+
}
309+
310+
throw new ObjectDisposedException(null, "Cannot access a closed stream.");
311+
}
312+
}
313+
179314
/// <inheritdoc/>
180315
public override void Flush()
181316
{
@@ -198,6 +333,7 @@ protected override void Dispose(bool disposing)
198333
{
199334
disposed = true;
200335
sequence = default;
336+
cursor = default;
201337
base.Dispose(disposing);
202338
}
203339

0 commit comments

Comments
 (0)