Module 6 · 6. Errors, Resources, Diagnostics, and Reliability · Lesson 17 of 24
IDisposable, await using, and Resource Ownership
The problem: a reference is not an ownership contract
You can hold a reference to a stream without having permission to close it. This lesson separates three questions: who owns the resource, how long must it remain usable, and whether cleanup itself needs asynchronous work. You will observe cleanup order, a failed acquisition, an awaited cleanup, and a lazy reader that outlives its owner.
Garbage collection reclaims managed memory; Dispose provides deliberate resource cleanup. An owner should release owned disposable members, while a borrower should not. Repeated Dispose calls should be harmless. A class holding only managed resources does not need its own finalizer; direct native-handle ownership is a separate advanced case, commonly handled with SafeHandle. Microsoft: implementing disposal.
A useful analogy is checking out of a borrowed room: removing an entry from your address book is not the checkout action. The limit is important: disposal is whatever the type’s cleanup contract defines, not always an operating-system handle being closed. Our probes simply record cleanup events so you can see the lifetime decisions.
First write an ownership sentence
- Owned: “This method creates the output and closes it before returning.” Its caller receives a completed value, not a stream it must keep alive
- Borrowed: “This method uses the supplied streams and leaves both open on success and failure.” The caller controls their lifetime
- Transferred: “This method returns an open resource and transfers disposal responsibility to the caller.” This needs explicit documentation; returning a reference alone is not enough to settle ownership
The examples below use owned local probes and borrowed method parameters. They never change the ownership of a passed-in object just because it implements IDisposable.
Experiment 1: observe scope exit rather than guessing
The using statement has try/finally semantics: acquired resources are disposed at scope exit in reverse order. Microsoft: using.
Put these files in a ScopeTrace folder and run dotnet run --project ScopeTrace.csproj -c Release with a .NET 10 SDK. This records strings only. The custom Probe classes are deliberately sealed, small test instruments rather than production wrappers for native handles.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
</Project>var events = new List<string>();
RunSync(events, failSecondAcquisition: false);
Console.WriteLine("body failure: " + string.Join(" > ", events));
events.Clear();
RunSync(events, failSecondAcquisition: true);
Console.WriteLine("acquisition failure: " + string.Join(" > ", events));
events.Clear();
await RunAsync(events);
Console.WriteLine("async exit: " + string.Join(" > ", events));
if (args.Contains("--self-test"))
{
var check = new List<string>();
RunSync(check, false);
Assert(string.Join(",", check) == "open A,open B,body,dispose B,dispose A,caught body", "reverse cleanup after body failure");
check.Clear();
RunSync(check, true);
Assert(string.Join(",", check) == "open A,acquire B failed,dispose A,caught acquisition", "only acquired resource is owned");
check.Clear();
await RunAsync(check);
Assert(string.Join(",", check) == "open async,work,dispose start,dispose end,after scope", "async cleanup awaited");
check.Clear();
var resource = new Probe("once", check);
resource.Dispose();
resource.Dispose();
Assert(string.Join(",", check) == "open once,dispose once", "fixture cleanup is idempotent");
Console.WriteLine("PASS: ScopeTrace semantic checks");
}
static void RunSync(List<string> events, bool failSecondAcquisition)
{
try
{
using var first = new Probe("A", events);
using var second = AcquireSecond(events, failSecondAcquisition);
events.Add("body");
throw new InvalidOperationException("body");
}
catch (InvalidOperationException ex)
{
events.Add("caught " + ex.Message);
}
}
static Probe AcquireSecond(List<string> events, bool fail)
{
if (fail)
{
events.Add("acquire B failed");
throw new InvalidOperationException("acquisition");
}
return new Probe("B", events);
}
static async Task RunAsync(List<string> events)
{
await using (var resource = new AsyncProbe(events))
{
events.Add("work");
}
events.Add("after scope");
}
static void Assert(bool condition, string label)
{
if (!condition) throw new Exception("Assertion failed: " + label);
}
public sealed class Probe : IDisposable
{
private readonly string name;
private readonly List<string> events;
private bool disposed;
public Probe(string name, List<string> events)
{
this.name = name;
this.events = events;
events.Add("open " + name);
}
public void Dispose()
{
if (disposed) return;
disposed = true;
events.Add("dispose " + name);
}
}
public sealed class AsyncProbe : IAsyncDisposable
{
private readonly List<string> events;
private bool disposed;
public AsyncProbe(List<string> events)
{
this.events = events;
events.Add("open async");
}
public async ValueTask DisposeAsync()
{
if (disposed) return;
disposed = true;
events.Add("dispose start");
await Task.Yield(); // Deliberately suspends; no real I/O occurs.
events.Add("dispose end");
}
}Expected output
body failure: open A > open B > body > dispose B > dispose A > caught body acquisition failure: open A > acquire B failed > dispose A > caught acquisition async exit: open async > work > dispose start > dispose end > after scope
Read the event order
- Body failure: A and B are both acquired. The body throws, B is cleaned first, then A, then the outer catch records the failure. The catch is outside the owning scope
- Acquisition failure: A exists, but acquiring B throws before returning a resource. A is cleaned; there is no B instance for the caller to clean. A constructor or factory that partially acquires its own internals must clean those internals itself
- Async exit: work finishes, disposal starts, the fixture suspends with Task.Yield, disposal ends, then after scope is recorded. The final event would be misleading if cleanup had been started and abandoned
IAsyncDisposable exposes asynchronous cleanup through DisposeAsync. Use await using when that supported cleanup path must be awaited; it does not make the body’s operations asynchronous. A sealed implementation can put cleanup directly in DisposeAsync. Microsoft: asynchronous disposal. Our Task.Yield models an actual suspension but does not flush a disk or contact a server. These probes are single-owner, sequential fixtures, not thread-safe cleanup implementations.
Run dotnet run --project ScopeTrace.csproj -c Release -- --self-test. After the same three demo lines, expect PASS: ScopeTrace semantic checks. The checks compare complete event sequences and verify that the synchronous probe records cleanup only once.
Experiment 2: borrow streams and keep a lazy reader alive
In a separate StreamLifetime folder, use the same project settings and this complete Program.cs. Run dotnet run --project StreamLifetime.csproj -c Release. The caller owns two in-memory streams. CopyBorrowedAsync uses them without closing either. No files are created or overwritten. This tiny fixture constructs both MemoryStreams before entering their owning scopes; it demonstrates completed acquisition and later cleanup. For real resources, establish ownership of the first resource before acquiring the next. ScopeTrace is the example that specifically exercises failed second acquisition.
using System.Text;
await Demo.RunAsync();
if (args.Contains("--self-test")) await Checks.RunAsync();
public static class Transfer
{
// Both streams are borrowed. This method must not dispose either one.
// Copy starts at the input's current position and may leave a partial output on failure.
public static async Task CopyBorrowedAsync(Stream input, Stream output, CancellationToken token)
{
ArgumentNullException.ThrowIfNull(input);
ArgumentNullException.ThrowIfNull(output);
await input.CopyToAsync(output, 81920, token);
await output.FlushAsync(token);
}
}
public static class Lines
{
// Broken on purpose: the iterator receives a reader whose owner already disposed it.
public static IEnumerable<string> Broken()
{
using var reader = new StringReader("red\nblue");
return ReadBorrowed(reader);
}
// Here the iterator owns the reader for the duration of enumeration.
public static IEnumerable<string> Owned()
{
using var reader = new StringReader("red\nblue");
while (reader.ReadLine() is string line) yield return line;
}
private static IEnumerable<string> ReadBorrowed(TextReader reader)
{
while (reader.ReadLine() is string line) yield return line;
}
}
public static class Demo
{
public static async Task RunAsync()
{
var input = new MemoryStream(Encoding.UTF8.GetBytes("sample"));
var output = new MemoryStream();
await using (input)
await using (output)
{
await Transfer.CopyBorrowedAsync(input, output, default);
Console.WriteLine("copied: " + Encoding.UTF8.GetString(output.ToArray()));
Console.WriteLine($"borrowed streams open: {input.CanRead && output.CanWrite}");
}
Console.WriteLine($"owner closed streams: {!input.CanRead && !output.CanWrite}");
try { _ = Lines.Broken().ToArray(); }
catch (ObjectDisposedException) { Console.WriteLine("broken lazy read: ObjectDisposedException"); }
Console.WriteLine("owned lazy read: " + string.Join(",", Lines.Owned()));
}
}
public static class Checks
{
public static async Task RunAsync()
{
await using var input = new MemoryStream(Encoding.UTF8.GetBytes("012345"));
await using var output = new MemoryStream();
input.Position = 2;
await Transfer.CopyBorrowedAsync(input, output, default);
Assert(Encoding.UTF8.GetString(output.ToArray()) == "2345", "current position respected");
Assert(input.CanRead && output.CanWrite, "borrowed streams remain open after success");
using var canceled = new CancellationTokenSource();
canceled.Cancel();
input.Position = 0;
await ThrowsAsync<OperationCanceledException>(
() => Transfer.CopyBorrowedAsync(input, output, canceled.Token));
Assert(input.CanRead && output.CanWrite, "borrowed streams remain open after cancellation");
await using var rejected = new RejectingStream();
input.Position = 0;
await ThrowsAsync<IOException>(() => Transfer.CopyBorrowedAsync(input, rejected, default));
Assert(input.CanRead && rejected.CanWrite, "borrowed streams remain open after failure");
Assert(rejected.WriteAttempts > 0, "the injected write failure was actually exercised");
bool failed = false;
IEnumerable<string> pending = Lines.Broken(); // Creating this sequence does not read a line.
try { _ = pending.ToArray(); }
catch (ObjectDisposedException) { failed = true; }
Assert(failed, "broken sequence fails during enumeration");
Assert(Lines.Owned().SequenceEqual(new[] { "red", "blue" }), "owned reader survives enumeration");
using (IEnumerator<string> iterator = Lines.Owned().GetEnumerator())
Assert(iterator.MoveNext() && iterator.Current == "red", "partial enumeration works");
Console.WriteLine("PASS: StreamLifetime semantic checks");
}
private static void Assert(bool condition, string label)
{
if (!condition) throw new Exception("Assertion failed: " + label);
}
private static async Task ThrowsAsync<T>(Func<Task> action) where T : Exception
{
try { await action(); }
catch (T) { return; }
throw new Exception("Expected " + typeof(T).Name);
}
}
// This test destination rejects writes without inheriting MemoryStream optimizations.
public sealed class RejectingStream : Stream
{
private bool disposed;
public int WriteAttempts { get; private set; }
public override bool CanRead => false;
public override bool CanSeek => false;
public override bool CanWrite => !disposed;
public override long Length => throw new NotSupportedException();
public override long Position
{
get => throw new NotSupportedException();
set => throw new NotSupportedException();
}
public override void Flush() => ObjectDisposedException.ThrowIf(disposed, this);
public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException();
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
public override void SetLength(long value) => throw new NotSupportedException();
public override void Write(byte[] buffer, int offset, int count) => throw Reject();
public override void Write(ReadOnlySpan<byte> buffer) => throw Reject();
public override void WriteByte(byte value) => throw Reject();
public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken token) =>
token.IsCancellationRequested ? Task.FromCanceled(token) : Task.FromException(Reject());
public override ValueTask WriteAsync(ReadOnlyMemory<byte> buffer, CancellationToken token = default) =>
token.IsCancellationRequested ? ValueTask.FromCanceled(token) : ValueTask.FromException(Reject());
private IOException Reject()
{
ObjectDisposedException.ThrowIf(disposed, this);
WriteAttempts++;
return new IOException("Fixture write failed.");
}
protected override void Dispose(bool disposing)
{
disposed = true;
base.Dispose(disposing);
}
}Expected output
copied: sample borrowed streams open: True owner closed streams: True broken lazy read: ObjectDisposedException owned lazy read: red,blue
Why the broken sequence fails later
Lines.Broken creates a reader, passes it to an iterator, then returns. Its local owning scope ends before anybody asks the iterator for a line. The later ToArray call starts reading through that already-disposed reader. The ObjectDisposedException is caught around enumeration, exactly where this fixture performs the failed read.
Lines.Owned puts resource acquisition and yielding in the same iterator. The reader lives with that enumeration rather than with an earlier factory call. Iterator code resumes as enumeration advances. Microsoft: iterators. The partial-enumeration test uses an explicit using around the enumerator. It checks the first value, while the full-enumeration test checks both values; neither claims to measure an operating-system handle.
An alternative for small data is to read and materialize all lines before leaving the owner’s scope. That trades memory for an independent result. It is inappropriate as a reflexive fix for arbitrarily large uploads; choose an iterator-owned resource or a clearly documented caller-owned stream instead.
What the copy method promises, and what it does not
- The copy starts at the input’s current position. The harness moves it to position 2 and expects “2345”, not “012345”
- The method does not dispose either borrowed stream. The harness checks both remain usable after success, pre-cancellation, and an injected write failure
- The caller’s await using scope closes both streams after the awaited operation. Do not return an unfinished copy task and let the owning scope end first
- The method does not roll back a partial destination after failure. It does not rewind the input, guarantee durable storage, or make copying atomic
- The method does not explicitly materialize the whole upload. Our MemoryStream demo already holds its tiny input and output in memory; this is a safe lifetime test, not proof of constant memory for every stream implementation
Run dotnet run --project StreamLifetime.csproj -c Release -- --self-test. After the five demo lines, expect PASS: StreamLifetime semantic checks. A canceled token is deliberately supplied, and RejectingStream fails writes in memory. Those are controlled test cases, not production storage failures.
Solved application: stream an upload without taking ownership accidentally
Problem. A request handler provides an upload stream. A storage adapter provides a destination. Implement copying without reading the entire upload into an array, and specify who closes what.
Solution. Reuse CopyBorrowedAsync. The handler keeps its request-owned input open until the awaited copy has ended. The destination owner keeps its destination open over the same interval, then awaits its cleanup. The copy method neither creates nor closes the streams. This allows the handler and storage layer to enforce their own resource contracts without a helper silently closing something they still need.
Failure policy. Define separately how the storage adapter treats a partially written upload: for example, an uncommitted temporary object can remain invisible until the adapter confirms completion. That policy is not implemented here, and blindly deleting an arbitrary destination is not part of resource disposal. Test size limits, cancellation, partial writes, failed flush and failed cleanup against the actual storage implementation before treating an upload as committed.
Solved lifetime review: an injected service
Problem. A request service receives a database dependency from the built-in DI container and disposes it at the end of one helper method. Another helper in the same request then fails. Who owns cleanup?
Solution. A container-created service belongs to its DI lifetime; a consumer should not dispose it. An externally constructed instance registered with the container remains the developer’s disposal responsibility. Microsoft: DI disposal guidelines. In the stated request case, remove the helper’s premature Dispose and let the owning scope finish. This conclusion depends on container ownership, not merely on the fact that a constructor parameter was injected.
Cleanup is not a transaction
A cleanup call can fail. An awaited operation that throws may already have written some data; disposing afterward does not reverse that work. Likewise, a failed cleanup is not evidence that the original operation succeeded. Preserve both operation and cleanup evidence at the owning boundary when that distinction matters. A process terminated immediately cannot be used as a demonstration that ordinary scope cleanup always ran.
Interview checks with answers
- Why use IDisposable with garbage collection? The application often needs a deliberate cleanup boundary, not just eventual managed-memory reclamation
- When does using var end its lifetime? At its containing scope’s exit. In ScopeTrace that is why A is still owned while B is being acquired
- Does await using guarantee a background thread? No. The fixture proves awaiting a cleanup that suspends; it does not establish any required thread placement
- What should a code review ask about every stream? Who owns it, when that lifetime ends, whether returned work still needs it, and what a failed operation leaves behind