runtime: Cannot unload a collectible AssemblyLoadContext if JsonConvert.Serialize() has been called on a custom object

If you execute Newtonsoft.Json.JsonConvert.Serialize() on a custom object defined in an assembly in a collectible AssemblyLoadContext, the AssemblyLoadContext can no longer be unloaded.

We noticed this issue in our testing as we are planning to use the collectible AssemblyLoadContext feature to port our plugin code from .NET framework to .NET Core 3.0. Our plugin code currently uses AppDomains and Json.NET for serializing the parameters and results for the plugin calls. Furthermore, the parameter structure classes are defined in the plugin assemblies, so this issue now blocks our .NET Core plugins from unloading.

The unload problem seems to be caused by TypeDescriptor caching: JsonConvert.Serialize() internally seems to call TypeDescriptor.GetConverter(type) on the custom object type, which adds it to the static TypeDescriptor caches. As the TypeDescriptor is loaded in the default LoadContext, the TypeDescriptor caches will keep the references to the custom types alive, and block the plugin assembly from unloading.

A simple reproduction based on the Unloading sample can be found here: https://github.com/jvuoti/samples/tree/jsonconvert_blocking_assemblyloadcontext_unload/core/tutorials/Unloading

In the sample, the Logger plugin dependency has been modified to serialize a CustomLogMessage object, also defined in the same plugin assembly:

    public class Logger
    {
        public class CustomLogMessage
        {
            public string LogMessage { get; set; }
        }

        public static void LogMessage(string msg)
        {
            var logMsg = JsonConvert.SerializeObject(new CustomLogMessage {LogMessage = msg});
            Console.WriteLine(logMsg);
        }
    }

Once the modified plugin code is called, the sample can no longer unload the AssemblyLoadContext.

The only way we have found to get the AssemblyLoadContext to unload is to first clear the internal TypeDescriptor caches via reflection, like this:

    var typeConverterAssembly = typeof(TypeConverter).Assembly;
    var reflectTypeDescriptionProviderType = typeConverterAssembly.GetType("System.ComponentModel.ReflectTypeDescriptionProvider");

    var reflectTypeDescriptorProviderTable = reflectTypeDescriptionProviderType.GetField("s_attributeCache", BindingFlags.Static | BindingFlags.NonPublic);
    var attributeCacheTable = (Hashtable)reflectTypeDescriptorProviderTable.GetValue(null);
    attributeCacheTable.Clear();

However, this does not really feel right 😃

So are we just doing things wrong, and would there be a simpler workaround for this? E.g. is there some way we could force a copy of the TypeConverter to be loaded inside the collectible AssemblyLoadContext, so the caches would also be inside it?

About this issue

  • Original URL
  • State: closed
  • Created 5 years ago
  • Comments: 15 (5 by maintainers)

Most upvoted comments

Thanks, after studying the test code here (as well as some trial and error), we finally got the unload working.

The trick was to load the netstandard.dll to the collectible AssemblyLoadContext, too. Otherwise System.ComponentModel.TypeConverter.dll was always loaded from the default context. Perhaps the type forwards in the netstandard assembly always point to the same load context?

Both assemblies also needed to be loaded to the AssemblyLoadContext when creating it, as was done in the tests. Trying to resolve them in the Load method seems to happen too late.

I updated the sample, and now the unload works there. Our own plugin load contexts also now seems unload cleanly after the changes. We’ll need to test it more to see if the explicit load of the assemblies causes causes issues, but so far everything seems to work.

Thanks for all the help!