Skip to content

Commit 0a60523

Browse files
thomhurstclaude
andauthored
fix: Don't dispose source stream by default in ToMemoryStreamAsync (#1724)
* fix: Don't dispose source stream by default in ToMemoryStreamAsync Fixes #1534 The ToMemoryStreamAsync extension method was unexpectedly disposing the input stream after copying, which is surprising behavior for an extension method. Additionally, behavior was inconsistent - MemoryStream inputs were returned as-is (not disposed), while other streams were disposed. Changes: - Add disposeSource parameter (defaults to false) - Reset output MemoryStream position to 0 for immediate reading - Also reset MemoryStream input position to 0 for consistency - Updated tests to pass disposeSource: true to prevent resource leaks Callers who want the old disposal behavior can pass disposeSource: true. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: Handle disposeSource consistently for MemoryStream inputs When disposeSource=true and input is a MemoryStream, now copies to a new MemoryStream before disposing the original. This ensures consistent behavior regardless of input stream type. Before: MemoryStream inputs were returned directly, ignoring disposeSource After: MemoryStream inputs are copied when disposeSource=true, then disposed * fix: Use await using for MemoryStream in tests Use await using declaration for MemoryStream results in tests for consistency and to ensure proper disposal patterns. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * docs: Add detailed remarks for ToMemoryStreamAsync breaking change Document: - Breaking change from previous behavior (now defaults to no disposal) - MemoryStream optimization behavior - Caller responsibility for disposing returned stream 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 828a48a commit 0a60523

2 files changed

Lines changed: 40 additions & 8 deletions

File tree

src/ModularPipelines/Extensions/StreamExtensions.cs

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,44 @@ public static class StreamExtensions
88
/// <summary>
99
/// Turns a generic <see cref="Stream"/> into a <see cref="MemoryStream"/>.
1010
/// </summary>
11+
/// <remarks>
12+
/// <para>
13+
/// <b>Breaking change:</b> This method previously always disposed the source stream.
14+
/// It now defaults to <c>disposeSource: false</c> to follow the principle of least surprise.
15+
/// Existing callers that relied on automatic disposal should pass <c>disposeSource: true</c>.
16+
/// </para>
17+
/// <para>
18+
/// When the input is already a <see cref="MemoryStream"/> and <paramref name="disposeSource"/> is false,
19+
/// the same instance is returned (with position reset) as an optimization.
20+
/// When <paramref name="disposeSource"/> is true, a copy is made before disposing the source.
21+
/// </para>
22+
/// <para>
23+
/// Callers are responsible for disposing the returned <see cref="MemoryStream"/>.
24+
/// </para>
25+
/// </remarks>
1126
/// <param name="stream">Any stream.</param>
12-
/// <returns>A MemoryStream containing the Stream's data.</returns>
13-
public static async Task<MemoryStream> ToMemoryStreamAsync(this Stream stream)
27+
/// <param name="disposeSource">
28+
/// When true, disposes the source stream after copying.
29+
/// Defaults to false - callers are responsible for disposing the source stream.
30+
/// </param>
31+
/// <returns>A MemoryStream containing the Stream's data, with Position set to 0 for reading.</returns>
32+
public static async Task<MemoryStream> ToMemoryStreamAsync(this Stream stream, bool disposeSource = false)
1433
{
15-
if (stream is MemoryStream memoryStream)
34+
// If input is already a MemoryStream and we don't need to dispose it,
35+
// return it directly (optimization to avoid unnecessary copy)
36+
if (stream is MemoryStream sourceMs && !disposeSource)
1637
{
17-
return memoryStream;
38+
if (sourceMs.CanSeek)
39+
{
40+
sourceMs.Position = 0;
41+
}
42+
43+
return sourceMs;
1844
}
1945

20-
memoryStream = new MemoryStream();
46+
// Copy to new MemoryStream (handles both non-MemoryStream inputs
47+
// and MemoryStream inputs when disposeSource is true)
48+
var memoryStream = new MemoryStream();
2149

2250
if (stream.CanSeek)
2351
{
@@ -26,8 +54,12 @@ public static async Task<MemoryStream> ToMemoryStreamAsync(this Stream stream)
2654

2755
await stream.CopyToAsync(memoryStream).ConfigureAwait(false);
2856

29-
await stream.DisposeAsync().ConfigureAwait(false);
57+
if (disposeSource)
58+
{
59+
await stream.DisposeAsync().ConfigureAwait(false);
60+
}
3061

62+
memoryStream.Position = 0;
3163
return memoryStream;
3264
}
3365
}

test/ModularPipelines.UnitTests/FileTests.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ public async Task ReadEmptyFile()
107107
var plainText = await file.ReadAsync();
108108
var lines = await ToListAsync(file.ReadLinesAsync());
109109
var bytes = await file.ReadBytesAsync();
110-
var stream = await file.GetStream().ToMemoryStreamAsync();
110+
await using var stream = await file.GetStream().ToMemoryStreamAsync(disposeSource: true);
111111

112112
using (Assert.Multiple())
113113
{
@@ -128,7 +128,7 @@ public async Task ReadWriteFile()
128128
var plainText = await file.ReadAsync();
129129
var lines = await ToListAsync(file.ReadLinesAsync());
130130
var bytes = await file.ReadBytesAsync();
131-
var stream = await file.GetStream().ToMemoryStreamAsync();
131+
await using var stream = await file.GetStream().ToMemoryStreamAsync(disposeSource: true);
132132

133133
using (Assert.Multiple())
134134
{

0 commit comments

Comments
 (0)