Skip to content

Fix circular init in AOT equality comparer causing NRE on Mono#16615

Merged
akoeplinger merged 1 commit intodotnet:mainfrom
agocke:fix-circularity
Mar 20, 2026
Merged

Fix circular init in AOT equality comparer causing NRE on Mono#16615
akoeplinger merged 1 commit intodotnet:mainfrom
agocke:fix-circularity

Conversation

@agocke
Copy link
Member

@agocke agocke commented Mar 20, 2026

The defaultComparer field in AssertEqualityComparer (AOT path) eagerly
creates AssertEqualityComparerAdapter(AssertEqualityComparer()).
This triggers AssertEqualityComparer's static initializer, which reads
DefaultInnerComparer = GetDefaultInnerComparer(typeof(object)), which reads
back defaultComparer — before it has been assigned. On Mono/WASM, this causes
DefaultInnerComparer to be permanently null. When comparing ValueTuple
elements via IStructuralEquatable.Equals, the null comparer is passed through,
causing NullReferenceException inside ValueTuple's implementation.

Fix by removing the cached static field and creating a new instance per call,
matching the non-AOT (reflection) path's behavior. The re-entrant type
initialization resolves correctly because the second access to
AssertEqualityComparer's cctor is a no-op (already in progress on the
same thread), so the instance constructor runs without re-triggering the
static initializer. By the time the outer call returns, DefaultInnerComparer
has been assigned.

…/WASM

The defaultComparer field in AssertEqualityComparer (AOT path) eagerly
creates AssertEqualityComparerAdapter<object>(AssertEqualityComparer<object>()).
This triggers AssertEqualityComparer<object>'s static initializer, which reads
DefaultInnerComparer = GetDefaultInnerComparer(typeof(object)), which reads
back defaultComparer — before it has been assigned. On Mono/WASM, this causes
DefaultInnerComparer to be permanently null. When comparing ValueTuple
elements via IStructuralEquatable.Equals, the null comparer is passed through,
causing NullReferenceException inside ValueTuple's implementation.

Fix by removing the cached static field and creating a new instance per call,
matching the non-AOT (reflection) path's behavior. The re-entrant type
initialization resolves correctly because the second access to
AssertEqualityComparer<object>'s cctor is a no-op (already in progress on the
same thread), so the instance constructor runs without re-triggering the
static initializer. By the time the outer call returns, DefaultInnerComparer
has been assigned.

Verified on both CoreCLR and Mono runtimes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@akoeplinger akoeplinger merged commit 853171a into dotnet:main Mar 20, 2026
9 checks passed
@agocke agocke deleted the fix-circularity branch March 20, 2026 21:21
agocke added a commit to agocke/assert.xunit that referenced this pull request Mar 20, 2026
The defaultComparer field in AssertEqualityComparer (AOT path) eagerly
creates AssertEqualityComparerAdapter<object>, which triggers
AssertEqualityComparer<object>'s static initializer. That initializer
reads defaultComparer back via GetDefaultInnerComparer before the field
has been assigned. On Mono/WASM this results in DefaultInnerComparer
being permanently null, leading to NullReferenceException when comparing
value types via IStructuralEquatable.

Fix by creating a new instance on each call, matching the non-AOT
reflection path behavior.

Port of dotnet/arcade#16615

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
bradwilson pushed a commit to xunit/assert.xunit that referenced this pull request Mar 20, 2026
The defaultComparer field in AssertEqualityComparer (AOT path) eagerly
creates AssertEqualityComparerAdapter<object>, which triggers
AssertEqualityComparer<object>'s static initializer. That initializer
reads defaultComparer back via GetDefaultInnerComparer before the field
has been assigned. On Mono/WASM this results in DefaultInnerComparer
being permanently null, leading to NullReferenceException when comparing
value types via IStructuralEquatable.

Fix by creating a new instance on each call, matching the non-AOT
reflection path behavior.

Port of dotnet/arcade#16615

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
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.

2 participants