Here is a simple example:
public class SafeHandleSpike
{
[Test]
// will fail
public void Fake()
{
var i1 = IO.Fake.Counter;
using (var obj = new Fake(true))
{
Assert.AreEqual(i1, IO.Fake.Counter);
// do things
}
Assert.AreEqual(i1 + 1, IO.Fake.Counter);
}
[Test]
public void Real()
{
var i1 = IO.Real.Counter;
using (var obj = new Real(true))
{
Assert.AreEqual(i1, IO.Real.Counter);
// do things
}
Assert.AreEqual(i1 + 1, IO.Real.Counter);
}
}
public class Fake : SafeHandleMinusOneIsInvalid
{
public static volatile int Counter;
public Fake(nint handle, bool ownsHandle) : base(ownsHandle)
{
SetHandle(handle);
}
public Fake(bool ownsHandle) : base(ownsHandle)
{
}
protected override bool ReleaseHandle()
{
Counter += 1;
return true;
}
}
public class Real : ActuallySafeHandle
{
public static volatile int Counter;
public Real(bool ownsHandle) : base(ownsHandle)
{
}
protected override bool ActualReleaseHandle()
{
Counter += 1;
return true;
}
}
public abstract class ActuallySafeHandle : SafeHandleMinusOneIsInvalid
{
public ActuallySafeHandle(bool ownsHandle) : base(ownsHandle)
{
}
public ActuallySafeHandle() : base(true)
{
}
private static readonly IntPtr InvalidHandleValue = new(-1);
public override bool IsInvalid => handle == InvalidHandleValue;
protected abstract bool ActualReleaseHandle();
protected sealed override bool ReleaseHandle()
{
if (!IsInvalid) return ActualReleaseHandle();
return false;
}
protected new void Dispose()
{
ActualReleaseHandle();
}
protected sealed override void Dispose(bool disposing)
{
ActualReleaseHandle();
}
}
When running the test, only the second test will succeed, the using
in the first test will call the Dispose() function and do nothing, despite that Fake
is already declared an IDisposable. I wasn’t able to observe if GC can invoke it.
Why is it an IDisposable if the Dispose()
function is useless?
8
One of the lines in the Dispose
logic for SafeHandle
is
performRelease = ((oldState & (StateBits.RefCount | StateBits.Closed)) == StateBits.RefCountOne) &&
_ownsHandle &&
!IsInvalid;
and then
if (performRelease)
{
.....
ReleaseHandle();
So ReleaseHandle
is only called if IsInvalid
property returns false
. But you are inheriting from SafeHandleMinusOneIsInvalid
, which overrides IsInvalid
and returns rather obviously false
in the case the handle is -1
.
Then, since you aren’t passing a valid handle in the constructor, it will always be invalid and therefore ReleaseHandle
will never be called. This is by design.
Your mistake is in the ActuallySafeHandle
, which isn’t safe at all. You are overriding Dispose
, and therefore dangerously messing up all the logic for the reference counting and thread-safety. Do not do this. SafeHandle
works perfectly fine if you give it a valid handle, it’s up to you to define what that valid handle is. SafeHandleMinusOneIsInvalid
is simply a shortcut to defining that.
So you would need a constructor that can create a SafeHandle
with a IntPtr
/nint
handle value. Normally this is done automatically by the PInvoke marshaller, but you can also do it from a constructor.
public class Fake : SafeHandleMinusOneIsInvalid
{
public static volatile int Counter;
public Fake(nint handle, bool ownsHandle) : base(ownsHandle)
{
base.SetHandle(handle);
}
protected override bool ReleaseHandle()
{
Counter += 1;
return true;
}
}
Also, note the actual purpose of SafeHandle
. It’s not necessary to use it just to create an IDisposable
. It’s specifically designed for thread-safe cleanup of OS handles, such as file, socket or kernel handles, and only where you actually have the native handle, not a managed object which holds it. The idea is: instead of passing or returning an IntPtr
in PInvoke, instead you pass around a SafeHandle
, which the marshaller understands and will ensure it’s not disposed early. It’s not intended for generalized usage.
4