Timo
Timo

Reputation: 9825

Value type equality and strongly named assemblies

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

Answers (1)

Timo
Timo

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

Related Questions