Skip to content

fix: support ref structs in value positions - #868

Open
vbreuss wants to merge 7 commits into
mainfrom
fix/ref-struct-value-positions
Open

vbreuss wants to merge 7 commits into
mainfrom
fix/ref-struct-value-positions

Conversation

@vbreuss

@vbreuss vbreuss commented Sep 15, 2026

Copy link
Copy Markdown
Member

A ref struct reaching a value position (method or delegate return, property type, indexer value, event argument) parameterized a generic that stores a Func and so cannot carry the allows ref struct anti-constraint, making the generated mock fail to compile with CS9244. Only parameter positions had been routed through the ref-struct pipeline; value positions were never gated, so instead of the intended graceful degradation the user got a raw compiler error on generated code they cannot edit.

Events and Span-typed properties are now genuinely supported: the IDefaultEventParameters raise overload is omitted for ref-struct arguments (it fills parameters via Generate, whose body type-tests an object against T and therefore cannot be anti-constrained) while the strongly-typed overload passes the value straight to the backing delegate, and property setup/verify facades now use the SpanWrapper carve-out the property body and the indexer facades already used, which also fixes the latent mismatch between a setup stored as Span and a body reading SpanWrapper.

The remaining shapes have no wrapper to route through and stay unsupported by design, so they degrade to the NotSupportedException stub already used for ref-struct-returning methods rather than breaking the build: non-span ref-struct returns on methods and delegates, ref-struct parameters on delegates, ref-struct properties, and ref-struct indexer values.

MockabilityAnalyzer had the matching blind spots - it flagged ref-struct method returns and indexer keys but not properties or indexer values - so every unsupported shape now reports Mockolate0003 at the CreateMock() call site instead of silently producing a throwing member.

The delegate Invoke body is extracted into its own method so the guard stays small; the move is otherwise verbatim.

A ref struct reaching a value position (method or delegate return, property type, indexer value, event argument) parameterized a generic that stores a Func<T> and so cannot carry the allows ref struct anti-constraint, making the generated mock fail to compile with CS9244. Only parameter positions had been routed through the ref-struct pipeline; value positions were never gated, so instead of the intended graceful degradation the user got a raw compiler error on generated code they cannot edit.

Events and Span-typed properties are now genuinely supported: the IDefaultEventParameters raise overload is omitted for ref-struct arguments (it fills parameters via Generate<T>, whose body type-tests an object against T and therefore cannot be anti-constrained) while the strongly-typed overload passes the value straight to the backing delegate, and property setup/verify facades now use the SpanWrapper carve-out the property body and the indexer facades already used, which also fixes the latent mismatch between a setup stored as Span<T> and a body reading SpanWrapper<T>.

The remaining shapes have no wrapper to route through and stay unsupported by design, so they degrade to the NotSupportedException stub already used for ref-struct-returning methods rather than breaking the build: non-span ref-struct returns on methods and delegates, ref-struct parameters on delegates, ref-struct properties, and ref-struct indexer values.

MockabilityAnalyzer had the matching blind spots - it flagged ref-struct method returns and indexer keys but not properties or indexer values - so every unsupported shape now reports Mockolate0003 at the CreateMock() call site instead of silently producing a throwing member.

The delegate Invoke body is extracted into its own method so the guard stays small; the move is otherwise verbatim.
@vbreuss vbreuss self-assigned this Sep 15, 2026
Copilot AI lite review requested due to automatic review settings September 15, 2026 08:37
@vbreuss vbreuss added the bug Something isn't working label Sep 15, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@github-actions

github-actions Bot commented Sep 15, 2026

Copy link
Copy Markdown

Test Results

    24 files  ±  0      24 suites  ±0   9m 37s ⏱️ - 2m 0s
 4 517 tests + 66   4 515 ✅ + 66  2 💤 ±0  0 ❌ ±0 
28 611 runs  +370  28 607 ✅ +370  4 💤 ±0  0 ❌ ±0 

Results for commit ca38990. ± Comparison against base commit 1995c72.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 15, 2026

Copy link
Copy Markdown

🚀 Benchmark Results

Details

BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.5 LTS (Noble Numbat)
AMD EPYC 7763 2.69GHz, 1 CPU, 4 logical and 2 physical cores
.NET SDK 10.0.401
[Host] : .NET 10.0.12 (10.0.12, 10.0.1226.42308), X64 RyuJIT x86-64-v3

Job=InProcess Toolchain=InProcessEmitToolchain IterationCount=15
LaunchCount=1 WarmupCount=10

Indexer N Mean Error StdDev Ratio Allocated Alloc Ratio
baseline* 1 866.5 ns 4.24 ns 3.97 ns 0.89 3.77 KB 1.00
Mockolate 1 977.7 ns 32.50 ns 30.40 ns 1.00 3.77 KB 1.00
Imposter 1 849.3 ns 14.77 ns 13.10 ns 0.87 5.16 KB 1.37
Moq 1 221,280.3 ns 1,231.91 ns 1,092.06 ns 226.52 20.25 KB 5.38
NSubstitute 1 10,131.8 ns 36.04 ns 31.95 ns 10.37 12.78 KB 3.39
FakeItEasy 1 12,105.4 ns 67.16 ns 59.54 ns 12.39 13.63 KB 3.62
baseline* 10 2,383.8 ns 6.36 ns 5.95 ns 0.96 4.82 KB 1.00
Mockolate 10 2,480.0 ns 24.58 ns 22.99 ns 1.00 4.82 KB 1.00
Imposter 10 2,221.6 ns 62.76 ns 58.71 ns 0.90 7.97 KB 1.65
Moq 10 233,204.5 ns 1,817.74 ns 1,700.32 ns 94.04 28.64 KB 5.94
NSubstitute 10 24,635.0 ns 208.19 ns 184.55 ns 9.93 25.58 KB 5.31
FakeItEasy 10 24,714.2 ns 207.32 ns 193.93 ns 9.97 32.97 KB 6.84
Details

BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.5 LTS (Noble Numbat)
AMD EPYC 7763 2.45GHz, 1 CPU, 4 logical and 2 physical cores
.NET SDK 10.0.401
[Host] : .NET 10.0.12 (10.0.12, 10.0.1226.42308), X64 RyuJIT x86-64-v3

Job=InProcess Toolchain=InProcessEmitToolchain IterationCount=15
LaunchCount=1 WarmupCount=10

Property N Mean Error StdDev Ratio Allocated Alloc Ratio
baseline* 1 485.9 ns 15.96 ns 14.93 ns 0.88 2.41 KB 1.00
Mockolate 1 550.3 ns 16.31 ns 15.26 ns 1.00 2.41 KB 1.00
Imposter 1 494.3 ns 21.86 ns 20.45 ns 0.90 3.13 KB 1.29
TUnitMocks 1 473.9 ns 9.42 ns 8.81 ns 0.86 1.64 KB 0.68
Moq 1 12,128.9 ns 45.50 ns 35.52 ns 22.06 10.39 KB 4.30
NSubstitute 1 8,041.1 ns 65.04 ns 60.84 ns 14.62 11.45 KB 4.74
FakeItEasy 1 8,321.8 ns 57.53 ns 51.00 ns 15.13 11.24 KB 4.66
baseline* 10 985.7 ns 15.18 ns 14.20 ns 0.97 2.91 KB 1.00
Mockolate 10 1,014.3 ns 7.92 ns 7.41 ns 1.00 2.91 KB 1.00
Imposter 10 1,097.1 ns 9.68 ns 9.06 ns 1.08 4.67 KB 1.61
TUnitMocks 10 1,614.8 ns 9.01 ns 8.43 ns 1.59 3.94 KB 1.35
Moq 10 19,325.3 ns 157.09 ns 139.26 ns 19.05 18.28 KB 6.29
NSubstitute 10 18,112.0 ns 172.24 ns 152.68 ns 17.86 21.08 KB 7.25
FakeItEasy 10 20,442.5 ns 147.27 ns 130.55 ns 20.15 30.81 KB 10.60
Details

BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.5 LTS (Noble Numbat)
AMD EPYC 7763 3.18GHz, 1 CPU, 4 logical and 2 physical cores
.NET SDK 10.0.401
[Host] : .NET 10.0.12 (10.0.12, 10.0.1226.42308), X64 RyuJIT x86-64-v3

Job=InProcess Toolchain=InProcessEmitToolchain IterationCount=15
LaunchCount=1 WarmupCount=10

Event Mean Error StdDev Ratio Allocated Alloc Ratio
baseline* 309.6 ns 12.60 ns 11.79 ns 1.07 1.7 KB 1.00
Mockolate 288.4 ns 7.74 ns 7.24 ns 1.00 1.7 KB 1.00
Imposter 1,403.4 ns 36.08 ns 33.75 ns 4.87 8.8 KB 5.17
TUnitMocks 196.1 ns 2.62 ns 2.32 ns 0.68 1.34 KB 0.79
Moq 16,228.6 ns 102.67 ns 96.04 ns 56.31 12.51 KB 7.34
NSubstitute 5,850.2 ns 75.28 ns 66.74 ns 20.30 9.05 KB 5.31
FakeItEasy 217,106.6 ns 1,377.25 ns 1,288.28 ns 753.29 15.26 KB 8.96
Details

BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.5 LTS (Noble Numbat)
INTEL XEON PLATINUM 8573C 2.30GHz, 1 CPU, 4 logical and 2 physical cores
.NET SDK 10.0.401
[Host] : .NET 10.0.12 (10.0.12, 10.0.1226.42308), X64 RyuJIT x86-64-v4

Job=InProcess Toolchain=InProcessEmitToolchain IterationCount=15
LaunchCount=1 WarmupCount=10

CreateMock Mean Error StdDev Ratio Allocated Alloc Ratio
baseline* 17.75 ns 0.622 ns 0.581 ns 1.23 160 B 1.00
Mockolate 14.47 ns 0.229 ns 0.214 ns 1.00 160 B 1.00
Imposter 230.81 ns 1.944 ns 1.819 ns 15.95 2248 B 14.05
TUnitMocks 30.74 ns 0.036 ns 0.030 ns 2.12 200 B 1.25
Moq 909.08 ns 2.378 ns 2.108 ns 62.83 2096 B 13.10
NSubstitute 1,440.69 ns 1.159 ns 0.905 ns 99.57 5048 B 31.55
FakeItEasy 1,071.39 ns 3.652 ns 3.238 ns 74.04 2759 B 17.24
Details

BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.5 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 4 logical and 2 physical cores
.NET SDK 10.0.401
[Host] : .NET 10.0.12 (10.0.12, 10.0.1226.42308), X64 RyuJIT x86-64-v4

Job=InProcess Toolchain=InProcessEmitToolchain IterationCount=15
LaunchCount=1 WarmupCount=10

Callback Mean Error StdDev Ratio Allocated Alloc Ratio
baseline* 314.8 ns 4.81 ns 4.02 ns 1.64 1.57 KB 1.00
Mockolate 192.5 ns 5.82 ns 5.44 ns 1.00 1.57 KB 1.00
Imposter 237.6 ns 9.85 ns 9.21 ns 1.24 2.38 KB 1.52
TUnitMocks 333.0 ns 11.93 ns 10.58 ns 1.73 1.99 KB 1.27
Moq 28,603.1 ns 461.20 ns 408.84 ns 148.72 8.88 KB 5.66
NSubstitute 2,326.3 ns 32.42 ns 28.74 ns 12.10 7.71 KB 4.91
FakeItEasy 2,469.4 ns 57.49 ns 50.97 ns 12.84 6.81 KB 4.33
Details

BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.5 LTS (Noble Numbat)
AMD EPYC 9V74 3.69GHz, 1 CPU, 4 logical and 2 physical cores
.NET SDK 10.0.401
[Host] : .NET 10.0.12 (10.0.12, 10.0.1226.42308), X64 RyuJIT x86-64-v4

Job=InProcess Toolchain=InProcessEmitToolchain IterationCount=15
LaunchCount=1 WarmupCount=10

Method N Mean Error StdDev Ratio Allocated Alloc Ratio
baseline* 1 402.1 ns 17.20 ns 15.25 ns 1.47 1.93 KB 1.00
Mockolate 1 273.2 ns 5.99 ns 5.60 ns 1.00 1.93 KB 1.00
Imposter 1 444.1 ns 3.97 ns 3.52 ns 1.63 4.04 KB 2.09
TUnitMocks 1 387.5 ns 2.55 ns 2.26 ns 1.42 2.02 KB 1.04
Moq 1 103,945.7 ns 775.32 ns 687.30 ns 380.59 14.59 KB 7.56
NSubstitute 1 4,451.2 ns 30.67 ns 28.69 ns 16.30 9.12 KB 4.72
FakeItEasy 1 4,314.4 ns 71.49 ns 66.88 ns 15.80 8.05 KB 4.17
baseline* 10 737.1 ns 9.44 ns 8.37 ns 1.52 2.14 KB 1.00
Mockolate 10 485.8 ns 3.97 ns 3.71 ns 1.00 2.14 KB 1.00
Imposter 10 862.0 ns 8.07 ns 7.16 ns 1.77 5.52 KB 2.58
TUnitMocks 10 1,109.5 ns 9.03 ns 8.45 ns 2.28 3.73 KB 1.74
Moq 10 108,010.9 ns 773.77 ns 723.79 ns 222.35 18.48 KB 8.64
NSubstitute 10 6,491.4 ns 87.34 ns 81.70 ns 13.36 12.07 KB 5.64
FakeItEasy 10 6,873.1 ns 48.18 ns 45.06 ns 14.15 15.42 KB 7.20

baseline* rows show the corresponding Mockolate benchmark from the most recent successful main branch build with results, for regression comparison.

A method that both returned and took a non-span ref struct was routed into the ref-struct parameter pipeline, which #errors below .NET 9, so a member that can never be supported anyway broke the whole compilation instead of degrading to a stub. The return type now decides first, in the generator and the analyzer alike, because it disqualifies the member regardless of target.

Verify now follows Setup for ref-struct returns: the body is a NotSupportedException stub, so the call never reaches interaction recording and the surface could only ever report zero - which is already why properties and indexers drop it.

Mockolate0003 called every member a "method" while reporting on properties and indexers, and its description still claimed out/ref ref-struct parameters were unmockable; the docs carried the same stale list and neither new value position. IsUnsupportedRefStructValue was a pure alias for NeedsRefStructPipeline, so a change to either would have silently redefined the other.

The tests were gated to net9.0+ even though none of these shapes are, hiding the targets where the old code broke hardest, and left five warnings in every build.
Copilot AI review requested due to automatic review settings September 15, 2026 09:04

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

… usable

A Span-valued indexer setter inferred `ApplyIndexerSetter<Span<T>>` and failed with CS9244, so the whole mock stopped compiling with no diagnostic pointing at it. The value type now goes through the same wrapper the getter and the property pipeline already use.

A ref struct in a value position still has no setup surface, but that is no reason to break a member that works: a virtual class member forwards to the wrapped instance or to base instead of throwing, which only interface and abstract members now do. A ref-struct return also no longer claims a fast-interaction buffer it can never record into.
The list still claimed `out`/`ref`/`ref readonly` ref-struct parameters are rejected, while the analyzer page this section is linked from as the authority says they are supported. It also predates the value-position and delegate-parameter carve-outs.
Copilot AI review requested due to automatic review settings September 15, 2026 09:39

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

…members

Span-valued properties and indexers were compilable but silently unverifiable: the value is matched through `EqualityComparer<SpanWrapper<T>>.Default`, and the wrapper had no `Equals`, so the by-value `Set(someSpan)` overload compiled and never matched. Content equality on both wrappers makes the form a user reaches for first work, rather than removing it in favour of the predicate matchers; `ToString` is there because recorded interactions render the wrapper, and the type name alone made the failure message useless.

A `virtual` class member with a ref struct in a value position keeps working - the override forwards to the wrapped instance or to `base` - so warning that it "cannot be mocked" only forced a suppression on code that needs no fix. The analyzer now mirrors the generator's forwarding condition, which also suppresses the parameter diagnostic for those members: the generator takes the return branch first, so their ref-struct parameters never reach the pipeline either.

The by-ref-return storage field was emitted before the body was known to be a stub, so a ref-struct return made the field itself illegal (CS8345) and broke the whole mock over a member that was already given up on - the opposite of what degrading is for.

The behaviour flags the passthrough bypasses are now written down instead of inferred: with no setup to honour them against, `SkipBaseClass` has nothing to return in the base call's place and `ThrowWhenNotSetup` would reject a member that can never be set up.

Tests cover the value-position accessors that had none: the setter paths on interfaces and classes, protected accessors, init-only getters, by-ref returns, and Span-typed members on classes rather than only on interfaces.
Copilot AI review requested due to automatic review settings September 15, 2026 20:12

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

The analyzer only ever modelled the type-level half of the generator's ref-struct rule, so `ref readonly Span<T>` slipped past it. That ref kind is the one span shape without a wrapper-based emit branch, so the generator routes it through the ref-struct pipeline like a custom ref struct: on a delegate it degrades to a NotSupportedException stub, and on an interface or class it needs .NET 9 / C# 13 or the emitted source hits an #error. Neither said anything at compile time.

Mirroring the parameter-level predicate in a deliberately distinct name, rather than a second overload, since the two rules disagree and silently picking the wrong one is what caused this.
Copilot AI review requested due to automatic review settings September 16, 2026 04:29

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@sonarqubecloud

Copy link
Copy Markdown

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

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants