Dispose Members In Dispose
ID |
csharp.dispose_members_in_dispose |
Severity |
high |
Remediation Complexity |
trivial |
Remediation Risk |
low |
Remediation Effort |
low |
Resource |
Reliability |
Language |
CSharp |
Tags |
api-design, idisposable, reliability, resource-leak |
Description
Reports a type that calls Dispose() on one of its own fields from a method other than its
Dispose method, when nothing reachable from Dispose releases that same field. Callers who
honour the disposal contract call Dispose and nothing else, so a field released only elsewhere
is leaked.
Rationale
The disposal contract in .NET has one entry point. using blocks, using declarations,
foreach over an enumerator, dependency-injection scopes and every collection type that owns its
items all converge on a single call to Dispose. Nothing calls Close, nothing calls Shutdown,
and nothing guesses that a private Cleanup needs running. So when the code that releases a field
lives outside Dispose and is not reachable from it, the field survives the one call the caller was
ever going to make.
The failure is quiet and delayed. The object looks correctly disposed — the type implements the
contract, the using block compiles, the review passes — while a file handle, a socket or a
database connection stays open until the finaliser runs, if the field’s type even has one. Under
load this is how a process runs out of handles or a connection pool starves, hours after the code
that caused it ran.
The same reasoning applies to a disposable ref struct. A ref struct cannot implement
IDisposable, and using binds to its Dispose method by shape rather than through an interface,
so what matters is where the release actually happens, not what the base list says.
Reachability is judged per field. A Close, Reset or Abort that mirrors what Dispose already
does — the usual .NET shape where both release the same handle — leaks nothing and is not reported;
only a field that no path from Dispose or DisposeAsync releases is a finding. A private helper
invoked from Dispose counts as part of the contract for the same reason. Finalisers are left alone
as well, since releasing there is a distinct concern with its own trade-offs.
The subject is restricted to fields the type actually owns: non-static, private or protected,
and of a type whose disposal is established (a well-known framework disposable, or a type in the
same file whose base list names IDisposable / IAsyncDisposable). A public disposable field is
assigned from outside, so its lifetime belongs to whoever assigned it and is explicitly compliant.
using System;
using System.IO;
public class Exporter : IDisposable
{
private FileStream output = new FileStream("out.bin", FileMode.Create);
private StreamWriter log = new StreamWriter("log.txt");
public void Close()
{
output.Dispose(); // FLAW — a caller who only calls Dispose leaks output
}
public void Dispose()
{
log.Dispose(); // OK — released by the contract
}
}
public class Session : IDisposable
{
private FileStream fs = new FileStream("s.bin", FileMode.Create);
public void Close()
{
fs.Dispose(); // OK — Dispose releases the same field, nothing leaks
}
public void Dispose()
{
fs.Dispose();
}
}
public class Importer : IDisposable
{
private FileStream input = new FileStream("in.bin", FileMode.Open);
public void Dispose()
{
ReleaseAll(); // OK — the helper is reachable from Dispose
}
private void ReleaseAll()
{
input.Dispose(); // OK — Dispose calls this
}
}
Remediation
Move the release into Dispose, or into a helper that Dispose calls. In the standard pattern that
helper is Dispose(bool disposing), which both Dispose() and the finaliser delegate to, so every
field is released exactly once regardless of which path ran.
If a method such as Close needs to stay for API reasons, make it delegate to Dispose rather than
release fields itself. That keeps one release path and removes the possibility of a caller reaching
only half of it.