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
-
Build a plugin containing:
[MemoryPackable]
public partial class PluginDto
{
public int Id { get; set; }
}
-
Load the plugin into new AssemblyLoadContext(name, isCollectible: true).
-
Create, serialize, and deserialize PluginDto inside the plugin. Return only a
stable value such as bool or int to the host.
-
Release plugin instances, Type, Assembly, reflection objects, delegates, and
exceptions; call Unload() from a non-inlined helper.
-
After that helper returns, run repeated GC/finalizer cycles.
-
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.
Support collectible AssemblyLoadContext with an instance-scoped serialization context
Description
MemoryPack.Coreis commonly loaded in the default, non-collectibleAssemblyLoadContext, while plugin assemblies can be loaded into a collectibleAssemblyLoadContext. A[MemoryPackable]type in such a plugin currently becomesrooted after it is serialized through the default API.
The current static
MemoryPackFormatterProviderstores formatter state in both aprocess-wide
ConcurrentDictionary<Type, IMemoryPackFormatter>and closed genericformatter caches. The resulting reference chain is:
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: dynamicunion composition does not provide lifetime isolation for formatter caches.
Minimal reproduction
Build a plugin containing:
Load the plugin into
new AssemblyLoadContext(name, isCollectible: true).Create, serialize, and deserialize
PluginDtoinside the plugin. Return only astable value such as
boolorintto the host.Release plugin instances,
Type,Assembly, reflection objects, delegates, andexceptions; call
Unload()from a non-inlined helper.After that helper returns, run repeated GC/finalizer cycles.
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:
The concrete context type is explicit on
Deserializebecause C# cannot infer aresult type from the assignment target. Keeping
TContextin the static signaturealso preserves unambiguous existing calls such as
Deserialize<T>(bytes, null)andallows the generated root formatter call to remain statically bound.
The generated type may expose a read-only
PluginMemoryPackContext.Defaultconvenience instance, but the registry and formatter graph are owned by each
context instance. There is no process-wide mutable
Current, no baseMemoryPackSerializerContext.Default, and noAsyncLocalresolver.Existing calls without a context remain unchanged and continue to use the legacy
static provider:
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 declaredon 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:
dependency graph for declared roots, including nested objects, arrays, lists,
dictionaries, unions, and nested generic shapes.
MemoryPackable types from another assembly without global registration.
objectboxing.Type map, delegate, or cache closed over a collectible type is context-owned.
MemoryPackSerializer.Serialize<T>/Deserialize<T>and the staticprovider remain source- and behavior-compatible.
Acceptance criteria
that the generated context path releases ALC, Assembly, and Type weak references.
formatter collisions; unloading one does not affect the other.
A[],List<A>, andDictionary<A, B>round-trip.nulloptions.target is no additional allocation and at least 99.9% of existing throughput,
with context construction reported separately.
NoGenerateworkaround, and migration.