Reputation: 9825
We're developing plugins at our company. In order to test our code better, we have to mock parts of the host application and use that fake assembly as a dependency in our tests. Since the original assembly is strongly named we have to disable strong name validation via the Windows registry, otherwise the mocked assembly won't be accepted as a disguise for the original assembly.
Within that assembly there is a type Point
. I will refer to the original type definition by Original.Point
and the mocked type by Mocked.Point
. To be clear, Mocked.Point
replaces Original.Point
. Both types have 3 float
fields for X, Y and Z. Original.Point
has a few properties and methods that Mocked.Point
doesn't have, but those shouldn't matter for equality.
Here is the source of Mocked.Point
:
public struct Point
{
public float X { get; }
public float Y { get; }
public float Z { get; }
public Point3f(float x, float y, float z)
{
X = x;
Y = y;
Z = z;
}
}
In our tests, we call a function Original.Point Host.GePoint()
. Host
refers to a third assembly from the plugin SDK. That assembly is not mocked. We call the function like this:
Mocked.Point a = Host.GetPoint();
There is no conversion happening here. Mocked.Point
is our replacement for Original.Point
. As far as the runtime is concerned, both types are the same.
Case A
However if we do an equality comparison between two instances, say
Mocked.Point a = Host.GetPoint();
Mocked.Point b = new(1, 0, 0); // b is equivalent to a, no rounding errors or anything
a.Equals(b); // returns false
the equality check fails. I verified that a.GetType() == b.GetType()
.
Case B
On the other hand, if we do
Mocked.Point a = new(1, 0, 0);
Mocked.Point b = new(1, 0, 0);
a.Equals(b); // returns true
the equality check succeeds.
I stepped through the source of System.ValueType
while debugging a.Equals(b)
:
[SecuritySafeCritical]
[__DynamicallyInvokable]
public override bool Equals(object obj)
{
if (obj == null)
return false;
RuntimeType type = (RuntimeType) this.GetType();
if ((RuntimeType) obj.GetType() != type)
return false;
object a = (object) this;
if (ValueType.CanCompareBits((object) this))
return ValueType.FastEqualsCheck(a, obj); // execution runs to here
FieldInfo[] fields = type.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
for (int index = 0; index < fields.Length; ++index)
{
object obj1 = ((RtFieldInfo) fields[index]).UnsafeGetValue(a);
object obj2 = ((RtFieldInfo) fields[index]).UnsafeGetValue(obj);
if (obj1 == null)
{
if (obj2 != null)
return false;
}
else if (!obj1.Equals(obj2))
return false;
}
return true;
}
and the execution path calls ValueType.FastEqualsCheck
in the end in both cases, which is a compiler intrinsic, so I cannot see what happens there (unless I look at the CLR source). This call does a memcmp
on both values (source ). This call returns false
in case A and true
in case B.
Question
Why does (or can) case A fail?
I know this a very sketchy scenario but we can't work around this issue in any other way.
Upvotes: 3
Views: 144
Reputation: 9825
I found the issue; this is a good one.
I tried to look at the memory that is compared in ValueType.FastEqualsCheck
, so I marshaled both objects
Mocked.Point a = Host.GetPoint();
Mocked.Point b = new(feetToMeter, 0, 0);
var size = Marshal.SizeOf<Point>();
var memA = new byte[size];
var memB = new byte[size];
unsafe
{
fixed (byte* pA = memA)
fixed (byte* pB = memB)
{
Marshal.StructureToPtr(a, (IntPtr) pA, false);
Marshal.StructureToPtr(b, (IntPtr) pB, false);
}
}
which gives me
memA = 48 F9 51 40 00 00 00 80 00 00 00 00
memB = 48 F9 51 40 00 00 00 00 00 00 00 00
Note that 80
in the 5th last place of memA
? That's part of the second float of the struct (Point.Y
). I looked up the bit representation of IEEE 754 floats and look at that it's the sign bit. So what happens here is that we compare -0f == 0f
which in floating point semantic is true, but since these two values have two different binary representations, bitwise comparison fails.
It doesn't help that the debugger swallows the sign when displayed, so -0f
and 0f
are both displayed as 0
. Furthermore, memberwise comparison is still successful because floating point semantics considers them equal.
This means, that a.Equals(b)
is false because the runtime uses the fast path via memcmp
. If it had taken the fallback (which is member wise comparison via reflection), a.Equals(b)
would've been true.
So in the end it had nothing to do with strong named assemblies. It's only an edge case in the value type equality semantics.
Upvotes: 4