Skip to content

fix(postconditions): Let collectible types that went through Old or EnsureAssignable be collected - #75

Merged
cwouyang merged 11 commits into
masterfrom
fix/collectible-type-caches
Oct 31, 2026
Merged

cwouyang merged 11 commits into
masterfrom
fix/collectible-type-caches

Conversation

@cwouyang

Copy link
Copy Markdown
Owner

Closes #48.

Summary

Three static per-type caches held their Type keys strongly: the shared-type classification (SharedTypes), the holds-references cache of the deep copy behind Old<T>() (DeepCopier), and the metadata cache of EnsureAssignable<T>() (MemberComparison). A collectible type that went through Old<T>() or EnsureAssignable<T>() (a plugin in a collectible AssemblyLoadContext, a RunAndCollect dynamic assembly) could therefore never be collected. Its load context could never unload either.

Changes

  • The three caches now go through the new internal TypeCache<TValue>, which has two tiers:
    • a ConcurrentDictionary for a type whose Type.IsCollectible is false;
    • a ConditionalWeakTable<Type, StrongBox<TValue>> for a collectible type, so the cache does not keep it alive.
  • IsCollectible is also true for a constructed type that is collectible only through a generic argument or an array element type, such as a List<PluginType>. That type's Assembly is System.Private.CoreLib, so the tier is not chosen with Assembly.IsCollectible.
  • A lookup reads the dictionary first. A hit proves the type is not collectible, so IsCollectible is read only on a miss.
  • A hit allocates nothing in either tier. Measured once in a Release microbenchmark (JIT, one machine), before and after the final fix of this branch:
Cache hit Before After
Not collectible 14.5–18.1 ns, 0 B 5.1–5.4 ns, 0 B
Collectible 22–27 ns, 88 B 19–20 ns, 0 B
  • Documentation: the XML doc of EnsureAssignable now names both tiers. ADR-0024 records the decision, the options and the measurements, and docs/adr/README.md lists it. CHANGELOG.md [Unreleased] has an entry under Fixed.

Not changed: comparison results, copies, exceptions and messages; PublicAPI.*.txt; trimming annotations.

Testing

  • All existing tests pass (dotnet test): 1239 of 1239 at the last commit.
  • New tests added for new functionality:
    • CollectibleTypeCacheTests: one collection test per cache, each failing before the fix because the type stayed alive. It also covers a List<T> and an array of a collectible type, and checks that the results for collectible and non-collectible types are the same.
    • TypeCacheTests: a hit for a non-collectible type allocates 0 bytes. This test failed at 24 bytes before its fix.
  • No mixed structural/behavioral changes. The extraction and the rename are separate refactor commits.
  • The Native AOT smoke program was published with -warnaserror (win-x64). All 107 checks pass with postconditions on. The DBC_POST=off variant was left to CI.
  • Reviews:
    • an independent review per task;
    • a risk review of the shared caches, both of the change and of its final fix;
    • a review of the whole branch.

Left open

  • No unit test pins that a hit for a collectible type allocates nothing, or that a dictionary hit skips IsCollectible. Only the microbenchmark shows them.
  • No test exercises the collectible tier from several threads. Its thread safety comes from ConditionalWeakTable.
  • The collection tests depend on the GC: at most 10 collections.
  • Unloading a real collectible AssemblyLoadContext is not tested. The tests use RunAndCollect assemblies.

Related Issues

Closes #48

…nsureAssignable be collected

SharedTypes, MemberComparison and DeepCopier cached every type in a static ConcurrentDictionary, which kept a collectible type and its assembly alive. A collectible type (Type.IsCollectible, which accounts for generic arguments and array element types) is now cached in a ConditionalWeakTable; other types keep the ConcurrentDictionary.

Refs #48
SharedTypes, MemberComparison and DeepCopier each chose between a ConcurrentDictionary and a ConditionalWeakTable by Type.IsCollectible. TypeCache<TValue> holds both tiers once.

Refs #48
…le type

TypeCache.GetOrAdd captured its factory in a lambda for the ConditionalWeakTable, so every call allocated a closure, also for a non-collectible type. The collectible branch now has a method of its own.

Refs #48
@cwouyang
cwouyang merged commit 6a42a97 into master Oct 31, 2026
7 checks passed
@cwouyang
cwouyang deleted the fix/collectible-type-caches branch October 31, 2026 10:44
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.

Per-type caches keep collectible types alive

1 participant