Conversation
…flection Reflection MethodInfo.Invoke does not auto-fill default-valued parameters the way a C# call site does. The source-gen Factory path emits a direct C# call so the compiler injects defaults, but the reflection fallback — taken for [InheritsTests] derived classes whose data-source method lives on a generic abstract base — passed the user-supplied Arguments array verbatim, throwing TargetParameterCountException whenever the data source declared an optional parameter (e.g. [EnumeratorCancellation] CancellationToken). Add BuildInvokeArgs to pad the args array to the method's parameter count, preferring ParameterInfo.DefaultValue, then CancellationToken.None for unannotated CT params, then falling back to the original array so genuine count mismatches still surface their natural error. Closes #5879
Up to standards ✅🟢 Issues
|
| Metric | Results |
|---|---|
| Complexity | 6 |
NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.
There was a problem hiding this comment.
Code Review
The fix correctly identifies and addresses the root cause: MethodInfo.Invoke does not auto-fill optional parameters the way a C# call site does, and the reflection fallback path for generic abstract base + [InheritsTests] wasn't accounting for this.
Regression: Truncating Extra Arguments
There is a subtle behavioural regression in BuildInvokeArgs when suppliedArguments.Length > parameters.Length:
// current code — enters the loop even when too many args are supplied
var args = new object?[parameters.Length];
for (var i = 0; i < parameters.Length; i++)
{
if (i < suppliedArguments.Length)
args[i] = suppliedArguments[i]; // fills params.Length slots from supplied
// ...
}
return args; // silently drops any extra supplied argumentsBefore the fix: methodInfo.Invoke(instance, suppliedArguments) with extra args → TargetParameterCountException (informative, expected).
After the fix: extra args are silently truncated and Invoke succeeds, masking a misconfigured [MethodDataSource] attribute.
Adding a guard before entering the loop restores the original behaviour for this case:
if (suppliedArguments.Length > parameters.Length)
return suppliedArguments; // let Invoke surface the mismatchSuggestion: Use Type.Missing Instead of Manual Default Filling
The current approach special-cases HasDefaultValue, then separately special-cases CancellationToken. A simpler, more general solution is to use Type.Missing — specifically designed for this scenario in reflection:
private static object?[] BuildInvokeArgs(MethodInfo methodInfo, object?[] suppliedArguments)
{
var parameters = methodInfo.GetParameters();
if (parameters.Length == suppliedArguments.Length)
return suppliedArguments;
if (suppliedArguments.Length > parameters.Length)
return suppliedArguments; // let Invoke surface the count mismatch
var args = new object?[parameters.Length];
suppliedArguments.CopyTo(args, 0);
for (var i = suppliedArguments.Length; i < parameters.Length; i++)
args[i] = Type.Missing;
return args;
}Why this is better:
Type.Missingis the .NET-documented idiom for "use the parameter's default value" in reflection — it works for all optional param types without enumerating them.- Eliminates the
CancellationToken-specific branch, so the method stays correct if future data-source methods have other optional parameter types (e.g.,bool flag = true,int count = 0). - For required parameters (no default),
InvokethrowsMissingMemberExceptionrather than the less-informativeTargetParameterCountException, giving clearer diagnostic output. - Supported on net8+ (all TUnit targets) —
Type.Missingfor non-COM optional params has worked since .NET Framework 4.0.
What's Good
- Root cause diagnosis in the PR description is accurate and clearly written.
- The
HasDefaultValue→CancellationToken.Nonefallback chain is correct for the reported scenario. - The regression test in
TUnit.TestProject/Bugs/5879/Repro.csis a clean minimal repro using the exact failing pattern —[InheritsTests], generic abstract base,[EnumeratorCancellation] CancellationToken ct = default. - The comment in the method explains why the helper exists (reflection vs compiler behaviour), which is the right kind of comment.
Summary
The fix works for the reported bug. The two items worth addressing before merge are the silent truncation regression (should be a one-liner guard) and considering Type.Missing to avoid the growing list of type-specific special cases as this code evolves.
|
Thanks for the review. Truncation regression — applied in af2d29d. Changed the early-return guard from
|
There was a problem hiding this comment.
Code Review — Follow-up
Previous Review Items
✅ Behavioral regression (surplus args) — addressed in af2d29d3
The updated guard if (parameters.Length <= suppliedArguments.Length) correctly handles both exact-match and surplus-arg cases by returning suppliedArguments unchanged, letting Invoke surface the TargetParameterCountException. Good fix.
Type.Missing suggestion — partially applicable, but current code is defensible
The previous review suggested replacing the manual default-filling with Type.Missing. On reflection (pun intended), there is a scenario where Type.Missing would not be a full replacement:
Type.Missingonly works for optional parameters that have a= defaultValuein the signature (HasDefaultValue == true).- For a
CancellationTokenparameter without a default value (e.g.,[EnumeratorCancellation] CancellationToken ct),Type.Missingwould causeInvoketo throw rather than usingCancellationToken.None.
The current HasDefaultValue → CancellationToken.None chain does handle that edge case. However, the CancellationToken branch is only reachable when HasDefaultValue == false, which is not the canonical [EnumeratorCancellation] CancellationToken ct = default pattern from the issue. If a data source method has a required CancellationToken parameter, that feels like a misuse of the API. Consider whether silently injecting CancellationToken.None is the right response there, versus surfacing an actionable error.
A middle ground worth considering:
private static object?[] BuildInvokeArgs(MethodInfo methodInfo, object?[] suppliedArguments)
{
var parameters = methodInfo.GetParameters();
if (parameters.Length <= suppliedArguments.Length)
return suppliedArguments;
var args = new object?[parameters.Length];
Array.Copy(suppliedArguments, args, suppliedArguments.Length);
for (var i = suppliedArguments.Length; i < parameters.Length; i++)
{
var p = parameters[i];
if (p.HasDefaultValue)
args[i] = Type.Missing; // .NET fills the declared default
else if (p.ParameterType == typeof(CancellationToken))
args[i] = CancellationToken.None; // pragmatic fallback for enumerator tokens
else
return suppliedArguments; // required param missing — let Invoke throw
}
return args;
}This uses Type.Missing for the common case (cleaner, works for all optional types without enumeration) while keeping the CancellationToken fallback as an explicit safety net. The Array.Copy + loop-from-offset avoids re-copying already-supplied args.
Overall
The fix is correct for the reported bug. The regression concern from the previous review was properly addressed. The code is in a mergeable state — the remaining suggestion is a simplification, not a correctness issue.
Approve pending the Type.Missing / loop refactor consideration (optional, not blocking).
Per @claude follow-up review on PR #5880. Functionally identical to the previous slot-by-slot fill, but: (a) only iterates the missing tail rather than every parameter, (b) Type.Missing is the documented .NET idiom for 'reflection-fill the declared default'. CancellationToken fallback kept as the explicit safety net for [Optional]-without-[DefaultParameterValue].
|
Applied the |
There was a problem hiding this comment.
Code Review — Final Sign-off
Previous Review Items — All Addressed
✅ Behavioral regression (surplus args) — Fixed in af2d29d. The guard if (parameters.Length <= suppliedArguments.Length) correctly passes both exact-match and surplus-arg arrays through to Invoke unchanged, preserving the original TargetParameterCountException behaviour.
✅ Type.Missing + Array.Copy refactor — Applied in 5833192. The final implementation is exactly the middle ground suggested in the previous review:
if (p.HasDefaultValue)
args[i] = Type.Missing; // .NET fills the declared default (works for all optional types)
else if (p.ParameterType == typeof(CancellationToken))
args[i] = CancellationToken.None; // pragmatic fallback for enumerator tokens without explicit default
else
return suppliedArguments; // required param missing — let Invoke throwThis is the correct design: Type.Missing handles the common case generically (no per-type enumeration), while the explicit CancellationToken branch is a narrow safety net for the [EnumeratorCancellation]-without-= default edge case. The Array.Copy + loop-from-offset avoids re-iterating already-supplied args.
Final State Assessment
The fix is correct, well-scoped, and the implementation is clean:
- Root cause accurately identified and fixed in the reflection invoke path
- No regression introduced for surplus-arg or exact-arg cases
- Generic handling via
Type.Missingwith a targetedCancellationTokensafety net - Regression test covers the exact failing pattern (
[InheritsTests]+ generic abstract base +[EnumeratorCancellation]optional CT) - PR description provides clear rationale for why the source-gen path wasn't affected
Approved. Ready to merge.
Summary
[InheritsTests]derived class on a generic abstract base + a[MethodDataSource]method with an optional parameter (e.g.[EnumeratorCancellation] CancellationToken ct = default) threwTargetParameterCountExceptionat discovery.MethodDataSourceAttribute.GetDataRowsAsynccalledmethodInfo.Invoke(instance, Arguments)withArguments == []; reflection'sInvokedoes not auto-fill defaults like a C# call site. The source-genFactorypath emits a direct C# call (compiler fills defaults), but the reflection fallback used for the generic abstract base case did not — so the optional CT parameter triggered the count mismatch.BuildInvokeArgs(MethodInfo, object?[])that pads the supplied args to the method's parameter count, filling missing slots withParameterInfo.DefaultValue, thenCancellationToken.NoneforCancellationToken-typed params without an explicit default, then falling back to the original array so genuine required-param mismatches still surface the originalTargetParameterCountException.Behaviour parity with source-gen
The source generator emits
DataSource()(TestMetadataGenerator.cs:1628) — the C# compiler then fillsdefault(CancellationToken). The reflection path now matches that exactly (CancellationToken.None). Plumbing a build-time CT throughIDataSourceAttribute.GetDataRowsAsyncwould be a public-API change and is out of scope for this bug fix.Test plan
TUnit.TestProject/Bugs/5879/Repro.cs— exact repro from the issue, marked[EngineTest(ExpectedResult.Pass)]. ThrowsTargetParameterCountExceptionbefore the fix; passes after.dotnet test TUnit.TestProject --treenode-filter "/*/*/Issue5879Tests/*"→ 1/1 pass.dotnet test TUnit.UnitTests→ 191/191 pass (no regressions in adjacent paths).