Skip to content

Support collectible AssemblyLoadContext with an instance-scoped serialization context #453

Description

@SunSi12138

Support collectible AssemblyLoadContext with an instance-scoped serialization context

Description

MemoryPack.Core is commonly loaded in the default, non-collectible
AssemblyLoadContext, while plugin assemblies can be loaded into a collectible
AssemblyLoadContext. A [MemoryPackable] type in such a plugin currently becomes
rooted after it is serialized through the default API.

The current static MemoryPackFormatterProvider stores formatter state in both a
process-wide ConcurrentDictionary<Type, IMemoryPackFormatter> and closed generic
formatter caches. The resulting reference chain is:

default ALC
  -> MemoryPack.Core static provider
  -> Type key / formatter value closed over PluginType
  -> collectible plugin Type and Assembly
  -> collectible AssemblyLoadContext

Static fields declared by the plugin are not sufficient by themselves to prevent
unloading. The leak is caused by static state in the non-collectible default ALC
holding references back into the collectible ALC.

This differs from #76, which was addressed by DynamicUnionFormatter: dynamic
union composition does not provide lifetime isolation for formatter caches.

Minimal reproduction

  1. Build a plugin containing:

    [MemoryPackable]
    public partial class PluginDto
    {
        public int Id { get; set; }
    }
  2. Load the plugin into new AssemblyLoadContext(name, isCollectible: true).

  3. Create, serialize, and deserialize PluginDto inside the plugin. Return only a
    stable value such as bool or int to the host.

  4. Release plugin instances, Type, Assembly, reflection objects, delegates, and
    exceptions; call Unload() from a non-inlined helper.

  5. After that helper returns, run repeated GC/finalizer cycles.

  6. Observe that weak references to the ALC, assembly, and plugin type remain alive.

The reproduction must keep host-side closed generics, async state machines, and
reflection caches out of the measurement to avoid false positives.

Proposed API and ownership

Add a source-generated, instance-scoped serialization context:

[MemoryPackSerializable(typeof(PluginDto))]
[MemoryPackSerializable(typeof(Dictionary<PluginA, PluginB>))]
public partial class PluginMemoryPackContext : MemoryPackSerializerContext
{
}

var context = new PluginMemoryPackContext(MemoryPackSerializerOptions.Utf8);
var bytes = MemoryPackSerializer.Serialize(value, context);
var result = MemoryPackSerializer.Deserialize<PluginDto, PluginMemoryPackContext>(bytes, context);

The concrete context type is explicit on Deserialize because C# cannot infer a
result type from the assignment target. Keeping TContext in the static signature
also preserves unambiguous existing calls such as Deserialize<T>(bytes, null) and
allows the generated root formatter call to remain statically bound.

The generated type may expose a read-only PluginMemoryPackContext.Default
convenience instance, but the registry and formatter graph are owned by each
context instance. There is no process-wide mutable Current, no base
MemoryPackSerializerContext.Default, and no AsyncLocal resolver.

Existing calls without a context remain unchanged and continue to use the legacy
static provider:

MemoryPackSerializer.Serialize(value);

This preserves source compatibility and the current hot path. It also means the
legacy path remains unsuitable for collectible plugin types.

A cross-assembly closed shape such as Dictionary<PluginA, PluginB> is declared
on a generated bridge context. Contexts are not combined dynamically in the first
version. The bridge context and the assemblies referenced by its generated code
form an explicit lifetime group.

Required behavior:

  • Generated code lives in the consumer/plugin assembly and expands the complete
    dependency graph for declared roots, including nested objects, arrays, lists,
    dictionaries, unions, and nested generic shapes.
  • Generated formatter factories allow a bridge context to consume context-enabled
    MemoryPackable types from another assembly without global registration.
  • The strongly typed path remains reflection-free and avoids object boxing.
  • Context resolution never falls back to or registers a type in the global provider.
  • Stable non-generic built-in formatters may remain globally shared, but a formatter,
    Type map, delegate, or cache closed over a collectible type is context-owned.
  • Existing MemoryPackSerializer.Serialize<T>/Deserialize<T> and the static
    provider remain source- and behavior-compatible.

Acceptance criteria

  • A deterministic regression fixture records the leak on current main and verifies
    that the generated context path releases ALC, Assembly, and Type weak references.
  • Two collectible ALCs can load assemblies with the same full type name without
    formatter collisions; unloading one does not affect the other.
  • Cross-plugin references and A[], List<A>, and Dictionary<A, B> round-trip.
  • Both plugin ALCs unload after the bridge context and external references are gone.
  • Context serialization does not mutate the global provider.
  • Existing static APIs remain compatible, including calls that pass null options.
  • Benchmarks compare the old static and generated context paths. The steady-state
    target is no additional allocation and at least 99.9% of existing throughput,
    with context construction reported separately.
  • Documentation explains ownership, lifetime coupling, safe sharing, the legacy
    NoGenerate workaround, and migration.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions