how come BinaryFormatter can serialize an Action but Json.net cannot

The reason that BinaryFormatter is (sometimes) able to round-trip an Action<T> is that such delegates are marked as [Serializable] and implement ISerializable.

However, just because the delegate itself is marked as serializable doesn’t mean that its members can be serialized successfully. In testing, I was able to serialize the following delegate:

Action<int> a1 = (a) => Console.WriteLine(a);

But attempting to serialize the following threw a SerializationException:

int i = 0;
Action<int> a2 = (a) => i = i + a;

The captured variable i apparently is placed in a non-serializable compiler-generated class thereby preventing binary serialization of the delegate from succeeding.

On the other hand, Json.NET is unable to round-trip an Action<T> despite supporting ISerializable because it does not provide support for serialization proxies configured via SerializationInfo.SetType(Type). We can confirm that Action<T> is using this mechanism with the following code:

var iSerializable = a1 as ISerializable;
if (iSerializable != null)
{
    var info = new SerializationInfo(a1.GetType(), new FormatterConverter());
    var initialFullTypeName = info.FullTypeName;
    iSerializable.GetObjectData(info, new StreamingContext(StreamingContextStates.All));
    Console.WriteLine("Initial FullTypeName = \"{0}\", final FullTypeName = \"{1}\".", initialFullTypeName, info.FullTypeName);
    var enumerator = info.GetEnumerator();
    while (enumerator.MoveNext())
    {
        Console.WriteLine("   Name = {0}, objectType = {1}, value = {2}.", enumerator.Name, enumerator.ObjectType, enumerator.Value);
    }
}

When run, it outputs:

Initial FullTypeName = "System.Action`1[[System.Int32, mscorlib, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089]]", final FullTypeName = "System.DelegateSerializationHolder".
   Name = Delegate, objectType = System.DelegateSerializationHolder+DelegateEntry, value = System.DelegateSerializationHolder+DelegateEntry.
   Name = method0, objectType = System.Reflection.RuntimeMethodInfo, value = Void <Test>b__0(Int32).

Notice that FullTypeName has changed to System.DelegateSerializationHolder? That’s the proxy, and it’s not supported by Json.NET.

This begs the question, just what is written out when a delegate is serialized? To determine this we can configure Json.NET to serialize Action<T> similarly to how BinaryFormatter would by setting

If I serialize a1 using these settings:

var settings = new JsonSerializerSettings
{
    TypeNameHandling = TypeNameHandling.All,
    ContractResolver = new DefaultContractResolver
    {
        IgnoreSerializableInterface = false,
        IgnoreSerializableAttribute = false,
    },
    Formatting = Formatting.Indented,
};
var json = JsonConvert.SerializeObject(a1, settings);
Console.WriteLine(json);

Then the following JSON is generated:

{
  "$type": "System.Action`1[[System.Int32, mscorlib]], mscorlib",
  "Delegate": {
    "$type": "System.DelegateSerializationHolder+DelegateEntry, mscorlib",
    "type": "System.Action`1[[System.Int32, mscorlib, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089]]",
    "assembly": "mscorlib, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089",
    "target": null,
    "targetTypeAssembly": "Tile, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null",
    "targetTypeName": "Question49138328.TestClass",
    "methodName": "<Test>b__0",
    "delegateEntry": null
  },
  "method0": {
    "$type": "System.Reflection.RuntimeMethodInfo, mscorlib",
    "Name": "<Test>b__0",
    "AssemblyName": "Tile, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null",
    "ClassName": "Question49138328.TestClass",
    "Signature": "Void <Test>b__0(Int32)",
    "MemberType": 8,
    "GenericArguments": null
  }
}

The replacement FullTypeName is not included but everything else is. And as you can see, it’s not actually storing the IL instructions of the delegate; it’s storing the full signature of the method(s) to call, including the hidden, compiler-generated method name <Test>b__0 mentioned in this answer. You can see the hidden method name yourself just by printing a1.Method.Name.

Incidentally, to confirm that Json.NET is really saving the same member data as BinaryFormatter, you can serialize a1 to binary and print any embedded ASCII strings as follows:

var binary = BinaryFormatterHelper.ToBinary(a1);
var s = Regex.Replace(Encoding.ASCII.GetString(binary), @"[^\u0020-\u007E]", string.Empty);
Console.WriteLine(s);
Assert.IsTrue(s.Contains(a1.Method.Name)); // Always passes

Using the extension method:

public static partial class BinaryFormatterHelper
{
    public static byte[] ToBinary<T>(T obj)
    {
        using (var stream = new MemoryStream())
        {
            new System.Runtime.Serialization.Formatters.Binary.BinaryFormatter().Serialize(stream, obj);
            return stream.ToArray();
        }
    }
}

Doing so results in the following string:

????"System.DelegateSerializationHolderDelegatemethod00System.DelegateSerializationHolder+DelegateEntry/System.Reflection.MemberInfoSerializationHolder0System.DelegateSerializationHolder+DelegateEntrytypeassemblytargettargetTypeAssemblytargetTypeNamemethodNamedelegateEntry0System.DelegateSerializationHolder+DelegateEntrylSystem.Action`1[[System.Int32, mscorlib, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089]]Kmscorlib, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089;Tile, Version=1.0.0.0, Culture=neutral, PublicKeyToken=nullQuestion49138328.TestClass<Test>b__0/System.Reflection.MemberInfoSerializationHolderNameAssemblyNameClassNameSignatureMemberTypeGenericArgumentsSystem.Type[]Void <Test>b__0(Int32)

And the assert never fires, indicating that the compiler-generated method name <Test>b__0 is indeed present in the binary also.

Now, here’s the scary part. If I modify my c# source code to create another Action<T> before a1, like so:

// I inserted this before a1 and then recompiled: 
Action<int> a0 = (a) => Debug.WriteLine(a);

Action<int> a1 = (a) => Console.WriteLine(a);

Then re-build and re-run, a1.Method.Name changes to <Test>b__1:

{
  "$type": "System.Action`1[[System.Int32, mscorlib]], mscorlib",
  "Delegate": {
    "$type": "System.DelegateSerializationHolder+DelegateEntry, mscorlib",
    "type": "System.Action`1[[System.Int32, mscorlib, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089]]",
    "assembly": "mscorlib, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089",
    "target": null,
    "targetTypeAssembly": "Tile, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null",
    "targetTypeName": "Question49138328.TestClass",
    "methodName": "<Test>b__1",
    "delegateEntry": null
  },
  "method0": {
    "$type": "System.Reflection.RuntimeMethodInfo, mscorlib",
    "Name": "<Test>b__1",
    "AssemblyName": "Tile, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null",
    "ClassName": "Question49138328.TestClass",
    "Signature": "Void <Test>b__1(Int32)",
    "MemberType": 8,
    "GenericArguments": null
  }
}

Now if I deserialize binary data for a1 saved from the earlier version, it comes back as a0! Thus, adding another delegate somewhere in your code base, or otherwise refactoring your code in an apparently harmless way, may cause previously serialized delegate data to be corrupt and fail or even possibly execute the wrong method when deserialized into the new version of your software. Further, this is unlikely to be fixable other than by reverting all changes out of your code and never making such changes again.

To sum up, we have found that serialized delegate information is incredibly fragile to seemingly-unrelated changes in one’s code base. I would strongly recommend against persisting delegates through serialization with either BinaryFormatter or Json.NET. Instead, consider maintaining a table of named delegates and serializing the names, or following the command pattern and serialize command objects.

Leave a Comment