Skip to content

Add generated serializer contexts for collectible AssemblyLoadContext#454

Draft
SunSi12138 wants to merge 4 commits into
Cysharp:mainfrom
SunSi12138:fix/collectible-alc-context
Draft

Add generated serializer contexts for collectible AssemblyLoadContext#454
SunSi12138 wants to merge 4 commits into
Cysharp:mainfrom
SunSi12138:fix/collectible-alc-context

Conversation

@SunSi12138

Copy link
Copy Markdown

Closes #453.

Summary

This adds a .NET 7+ source-generated, instance-scoped serializer context for types that must remain unloadable with a collectible AssemblyLoadContext.

[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 copy = MemoryPackSerializer.Deserialize<PluginDto, PluginMemoryPackContext>(bytes, context);

Each context instance owns its formatter registry, closed formatter graph, type map, and generated materializer delegates. The generated PluginMemoryPackContext.Default is a read-only static on the plugin's context type, so it belongs to that assembly's lifetime. There is no process-wide context Current, base Default, AsyncLocal, or options-based mutable resolver.

The existing no-context APIs and MemoryPackFormatterProvider remain in place. The old serializer, writer, reader, and provider hot paths are not modified. A type included in a context stops eager global registration, but its existing IMemoryPackFormatterRegister.RegisterFormatter() implementation remains available and the legacy static API registers it lazily when explicitly used.

Lifetime model

The legacy retention chain is:

MemoryPack.Core in the default ALC
  -> static formatter dictionary / closed formatter cache
  -> plugin Type key, formatter, generic argument, or delegate
  -> plugin Assembly / LoaderAllocator
  -> collectible AssemblyLoadContext

Plugin-owned static fields only form references within the collectible lifetime and do not create this reverse root. GenerateType.NoGenerate is also not sufficient by itself: the manual workaround succeeds only when generated eager registration is avoided, RegisterFormatter() does not register globally, and every used packable/container path avoids the static provider.

The context path builds a separate generated graph with injected dependencies and no provider fallback. It covers objects, version-tolerant and circular-reference types, generated collections, unions, Type, nullable values, arrays through rank four, list/dictionary and array-like shapes, tuples, interface/concurrent/immutable collections, and .NET 8 frozen collections. Closed generic MemoryPackable types are expanded from their concrete context arguments.

Cross-assembly MemoryPackable dependencies require a generated context factory in their declaring assembly. A bridge context can then declare shapes such as Dictionary<PluginA, PluginB> without runtime context composition. The bridge and both referenced plugins intentionally form one lifetime group.

Type uses a context-local wire-name map. A type not present in the generated graph fails instead of falling back to Type.GetType or the global provider. FormatterType can provide an external leaf or nested dependency, and an advanced formatter can implement IMemoryPackSerializerContextFormatterFactory<T>.

Compatibility

  • Existing Serialize<T>/Deserialize<T> overloads and wire format remain compatible.
  • Existing null options calls and method-group conversions have compile tests.
  • Context/static bytes are cross-deserialized and compared in tests.
  • The strongly typed context path uses a concrete TContext and static-abstract generated entry point; it does not add Type lookup, reflection, or boxing to the hot path.
  • Non-generic Type/object APIs are available on the context for tooling and may box by design.
  • Contexts are immutable after construction, concurrent-read safe, and do not implement IDisposable; removing all strong references releases ownership.
  • The feature is compiled only for .NET 7+. Existing netstandard and Unity paths are unchanged.

Stateless Core/BCL leaf formatters that cannot reference a collectible type can still be shared. A global Type key, reflected member, plugin formatter instance, delegate, or container formatter closed over a plugin type is not safe to share. A user formatter that explicitly calls MemoryPackFormatterProvider.Register opts out of context isolation.

Tests

  • Release solution test: 182 passed, 0 failed.
  • Release solution pack: 12 NuGet and symbol packages produced.
  • A generated context was separately built for net7.0 and round-tripped successfully.
  • The Core package builds for netstandard2.1, net7.0, and net8.0; Unity shims build for the same existing targets.
  • Collectible regression tests use synchronous non-inlined helpers, stable host return values, explicit reference cleanup, Unload(), repeated collection/finalizer cycles, and separate weak references for the ALC, assembly, and plugin type.
  • Coverage includes two ALCs loading the same assembly/full type name, independent unload, generated Default, nested Type, exception paths, non-generic context APIs, arrays/lists/dictionaries, a cross-ALC object reference, and a bridge context that holds then releases both ALCs.
  • A legacy-path test records the expected global-provider root, while context tests inspect the provider dictionaries and prove no plugin type, closed formatter, or factory entry was added.
  • Generator tests cover unsupported/external graphs, context diagnostics, closed generic MemoryPackable types, formatter overrides, incremental caching, and exact unchanged output for an unrelated legacy formatter.

Performance validation

The PR includes BenchmarkDotNet coverage for simple DTOs, deep graphs, arrays, lists, dictionaries, unions, byte arrays, IBufferWriter, sequence deserialization, streams, and context construction. It also includes a context-free static-path baseline benchmark.

The established static serializer/provider/writer/reader implementations are unchanged, and a generator regression test verifies byte-for-byte identical generated source for a type unrelated to a context. Short local smoke runs showed identical steady-state allocation counts, but their timing variance is too large to support a 0.1% claim. Repeated-launch equivalence measurements can be attached separately; no noisy shared-CI hard gate is introduced here.

Intentional limitations

  • Contexts are not composed dynamically. Cross-plugin shapes need an explicit bridge context.
  • Strongly typed Deserialize spells both generic arguments because C# cannot infer the result type, and this preserves existing Deserialize<T>(bytes, null) binding.
  • Keeping a context or bridge instance alive intentionally keeps every assembly represented by its graph alive.
  • Calling the old static API for a collectible type still enters the legacy provider and can prevent unload.

@SunSi12138

Copy link
Copy Markdown
Author

Hi @neuecc, when you have time, I’d really appreciate any early feedback on whether this source-generated context direction fits MemoryPack. Thank you!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support collectible AssemblyLoadContext with an instance-scoped serialization context

1 participant