fix: Don't dispose source stream by default in ToMemoryStreamAsync#1724
Conversation
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>
SummaryThis PR improves the Critical Issues1. Inconsistent disposal behavior when input is already a MemoryStream The if (stream is MemoryStream memoryStream)
{
if (memoryStream.CanSeek)
{
memoryStream.Position = 0;
}
return memoryStream; // Bug: doesn't respect disposeSource parameter
}Impact: If a caller passes Fix: Add disposal logic before returning: if (stream is MemoryStream memoryStream)
{
if (memoryStream.CanSeek)
{
memoryStream.Position = 0;
}
if (disposeSource)
{
await memoryStream.DisposeAsync().ConfigureAwait(false);
}
return memoryStream;
}Wait, that won't work because we're returning the same instance we just disposed. The semantics here need clarification:
Alternatively, document that SuggestionsConsider the MemoryStream input semantics more carefully The current behavior returns the same MemoryStream instance that was passed in (just with position reset). This has implications:
One option: Always create a new MemoryStream copy when if (stream is MemoryStream sourceMs)
{
if (!disposeSource)
{
if (sourceMs.CanSeek)
{
sourceMs.Position = 0;
}
return sourceMs;
}
// disposeSource is true, so copy to new MemoryStream
// (fall through to regular copy logic below)
}Previous Review StatusNo previous comments. Verdict |
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
SummaryChanges Critical IssuesResource leak in tests - The test updates only add The pattern in FileTests.cs:110,131 should be: // Current (leaks the FileStream from GetStream())
var stream = await file.GetStream().ToMemoryStreamAsync(disposeSource: true);
// Should be one of:
// Option 1: Dispose source stream (what the test intends)
await using var sourceStream = file.GetStream();
var stream = await sourceStream.ToMemoryStreamAsync(disposeSource: false);
// Option 2: Use using declaration with the result
await using var stream = await file.GetStream().ToMemoryStreamAsync(disposeSource: true);The current code passes Suggestions
Previous Review StatusNo previous review comments found. Verdict |
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>
There was a problem hiding this comment.
Pull request overview
This PR fixes unexpected stream disposal behavior in the ToMemoryStreamAsync extension method by making disposal opt-in rather than automatic. The change addresses issue #1534 where the method was disposing input streams without caller control, which is surprising for an extension method.
Key changes:
- Added
disposeSourceparameter (defaults tofalse) to give callers explicit control over source stream disposal - Added position reset logic to ensure returned MemoryStream is ready for immediate reading
- Updated existing tests to pass
disposeSource: trueto maintain previous behavior and prevent resource leaks
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
src/ModularPipelines/Extensions/StreamExtensions.cs |
Added disposeSource parameter with default value false, conditional disposal logic, position reset for output stream, and improved optimization logic for MemoryStream inputs |
test/ModularPipelines.UnitTests/FileTests.cs |
Updated two test methods to explicitly pass disposeSource: true to maintain previous disposal behavior |
| /// Defaults to false - callers are responsible for disposing the source stream. | ||
| /// </param> | ||
| /// <returns>A MemoryStream containing the Stream's data, with Position set to 0 for reading.</returns> | ||
| public static async Task<MemoryStream> ToMemoryStreamAsync(this Stream stream, bool disposeSource = false) |
There was a problem hiding this comment.
The new default behavior (disposeSource: false) lacks test coverage. Consider adding tests that verify:
- The source stream is NOT disposed when disposeSource is false (or omitted)
- The returned MemoryStream can be read (Position is 0)
- Behavior with MemoryStream inputs vs other Stream types
- The source stream IS disposed when disposeSource is true
SummaryFixes resource disposal issues in Critical Issues1. Breaking Change - Default Behavior Changed
|
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>
SummaryChanges Critical IssuesNone found ✅ Suggestions1. Resource Leak Risk in Tests (Lines 110, 131 in FileTests.cs)The current implementation has a potential resource leak. In the tests, you're calling: await using var stream = await file.GetStream().ToMemoryStreamAsync(disposeSource: true);However, Recommended fix: // Option 1: Use intermediate variable
await using var sourceStream = file.GetStream();
await using var stream = await sourceStream.ToMemoryStreamAsync(disposeSource: true);
// Option 2: Since we're disposing anyway, just use the file stream directly for testing
await using var stream = file.GetStream();
// Read from stream for assertions...Given the tests are just reading content, you might not even need 2. Consider Position Reset Edge CaseIn While MemoryStream is always seekable, for consistency and safety with the public API contract (accepting any Stream), consider: if (memoryStream.CanSeek)
{
memoryStream.Position = 0;
}
return memoryStream;3. Documentation NoteThe breaking change is well-documented in XML docs. Consider also:
The explicit documentation about the breaking change is excellent - users will understand the migration path clearly. Verdict✅ APPROVE - No critical issues The core implementation is solid. The suggestions are minor improvements around resource management best practices and edge case handling. The breaking change is well-justified (principle of least surprise) and properly documented. |
Summary
Fixes #1534
The
ToMemoryStreamAsyncextension method was unexpectedly disposing the input stream after copying, which is surprising behavior for an extension method.Problems Fixed
MemoryStreaminputs were returned as-is (not disposed), while other streams were disposedChanges
disposeSourceparameter (defaults tofalse)MemoryStreamposition to 0 for immediate readingMemoryStreaminput position to 0 for consistencydisposeSource: trueto prevent resource leaksMigration
Existing callers who relied on the disposal behavior should update their calls:
Test plan
🤖 Generated with Claude Code