Skip to content

Fix StreamWriter keeps working after being disposed if leaveOpen: true is used#125189

Open
ViveliDuCh wants to merge 3 commits intodotnet:mainfrom
ViveliDuCh:fix/streamwriter-dispose-leaveopen-89646
Open

Fix StreamWriter keeps working after being disposed if leaveOpen: true is used#125189
ViveliDuCh wants to merge 3 commits intodotnet:mainfrom
ViveliDuCh:fix/streamwriter-dispose-leaveopen-89646

Conversation

@ViveliDuCh
Copy link
Member

Fixes #89646

Description

StreamWriter created with leaveOpen: true silently continues accepting writes after Dispose() / DisposeAsync() instead of throwing ObjectDisposedException. StreamReader with the same flag correctly throws.

The root cause is in CloseStreamFromDispose: the _disposed = true assignment was gated behind if (_closable && !_disposed), where _closable is false when leaveOpen: true. This meant _disposed was never set to true, so ThrowIfDisposed() never fired.

var ms = new MemoryStream();
var sw = new StreamWriter(ms, leaveOpen: true);
sw.Write("Hello");
sw.Dispose();
sw.Write(" World"); // No exception thrown — bug

Fix

Restructured CloseStreamFromDispose so _disposed = true, _charLen = 0, and base.Dispose(disposing) execute unconditionally in the finally block, while _closable now only gates _stream.Close(). This mirrors the pattern already used by StreamReader in StreamReader.Dispose(bool):

 private void CloseStreamFromDispose(bool disposing)
 {
-    if (_closable && !_disposed)
+    if (!_disposed)
     {
         try
         {
-            if (disposing)
+            if (_closable && disposing)
             {
                 _stream.Close();
             }
         }
         finally
         {
             _disposed = true;
             _charLen = 0;
             base.Dispose(disposing);
         }
     }
 }

NullStreamWriter (backing StreamWriter.Null) is not affected — it overrides both Dispose(bool) and DisposeAsync() as no-ops and never calls CloseStreamFromDispose.

Testing

Added tests to existing test files (no new files, no csproj changes needed):

  • CloseTests.AfterDisposeThrows_LeaveOpenTrue : sync Dispose with leaveOpen: true.
  • StreamWriterTests.DisposeAsync_LeaveOpenTrue_ThrowsAfterDispose : async DisposeAsync with leaveOpen: true
  • StreamReaderTests.ObjectDisposedExceptionDisposedStream_LeaveOpenTrue: confirms StreamReader parity.

Full System.IO.Tests suite: 1712 tests, 0 regressions (1 pre-existing unrelated failure in MemoryStream_CapacityBoundaryChecks).

…roring the StreamReader.Dispose pattern. Add tests for Dispose and DisposeAsync with leaveOpen: true, plus a StreamReader parity test.
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes a StreamWriter disposal bug where leaveOpen: true prevented the writer from transitioning to a disposed state, allowing post-Dispose() / DisposeAsync() writes to proceed instead of throwing ObjectDisposedException. This aligns StreamWriter behavior with StreamReader and expected .NET disposed-object semantics.

Changes:

  • Update StreamWriter.CloseStreamFromDispose so _disposed is set (and internal state is cleared) regardless of leaveOpen, while _closable only gates closing the underlying stream.
  • Add regression tests for sync Dispose() + leaveOpen: true and async DisposeAsync() + leaveOpen: true.
  • Add a parity test confirming StreamReader throws after disposal when leaveOpen: true.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated no comments.

File Description
src/libraries/System.Private.CoreLib/src/System/IO/StreamWriter.cs Ensures StreamWriter becomes disposed even when leaveOpen: true, preventing further writes after disposal.
src/libraries/System.Runtime/tests/System.IO.Tests/StreamWriter/StreamWriter.DisposeAsync.cs Adds regression coverage for DisposeAsync() with leaveOpen: true throwing on subsequent writes.
src/libraries/System.Runtime/tests/System.IO.Tests/StreamWriter/StreamWriter.CloseTests.cs Adds regression coverage for Dispose() with leaveOpen: true throwing on subsequent operations.
src/libraries/System.Runtime/tests/System.IO.Tests/StreamReader/StreamReaderTests.cs Adds a test asserting StreamReader throws after disposal when leaveOpen: true (behavior parity reference).

Copy link
Member

@stephentoub stephentoub left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is likely to break someone. I think it's ok to make, as we say that use-after-dispose is erroneous. But we might want to consider marking it as a breaking change.

@ViveliDuCh ViveliDuCh added the breaking-change Issue or PR that represents a breaking API or functional change over a previous release. label Mar 4, 2026
@dotnet-policy-service dotnet-policy-service bot added the needs-breaking-change-doc-created Breaking changes need an issue opened with https://github.com/dotnet/docs/issues/new?template=dotnet label Mar 4, 2026
@ViveliDuCh
Copy link
Member Author

ViveliDuCh commented Mar 5, 2026

@dotnet/compat This fix changes StreamWriter to throw ObjectDisposedException after Dispose() when leaveOpen: true. Previously, writes silently succeeded. This aligns with StreamReader behavior and standard IDisposable semantics. We believe this is a Bucket 2 behavioral change with low practical risk since use-after-dispose is always erroneous.

Copilot AI review requested due to automatic review settings March 5, 2026 18:00
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Console.Out is a process-wide singleton wrapping stdout. Disposing it is always a mistake like disposing StreamWriter.Null. The fact that it survived dispose calls before was an accident (caused by the bug we're fixing). The NonClosableStreamWriter is making explicit what was previously implicit. It's 6 lines of logic and follows the exact same pattern as NullStreamWriter and NullStreamReader, which exist for the same reason.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-System.IO breaking-change Issue or PR that represents a breaking API or functional change over a previous release. needs-breaking-change-doc-created Breaking changes need an issue opened with https://github.com/dotnet/docs/issues/new?template=dotnet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Breaking Change] StreamWriter keeps working after being disposed if leaveOpen: true is used

4 participants