Assert.That: fix Expression.Assign double-evaluation and assorted cleanups#8363
Conversation
…anups Fixes a single-pass guarantee violation for `Expression.Assign` (and compound assignment forms): the RHS of the assignment was evaluated once during the bottom-up walk, but the generic 'rebuild and compile' path then produced an invalid `Assign(Constant, Constant)` that the outer catch sentineled, and the parent expression's later rebuild re-invoked the original assignment from scratch — running the RHS side effects a second time. Concretely: - `EvaluateAllSubExpressions` now special-cases assignment node types (`Assign` and the compound forms `AddAssign` / `SubtractAssign` / etc., plus the unary `PreIncrementAssign` / `PostIncrementAssign` family). It walks Left to capture diagnostics, walks Right exactly once, then rebuilds via `ReplaceSubExpressionsWithConstants` so the writable wrapper node (`MemberExpression` / `IndexExpression` / `ParameterExpression`) is preserved while its sub-children become constants. This also avoids double evaluation of side-effecting Left sub-children (e.g., `provider.GetBox().Value = 42`). - `RequiresSinglePassEvaluation` now recognizes assignment node types so expressions containing assignments always take the single-pass evaluator, closing a fast-path leak where an assignment with constant RHS would run once in the fast path and again on the failure-diagnostic path. Other cleanups: - Removed dead `IsSimpleExpression` and `IsVariableReference` (every case in `IsSimpleExpression` returned `false` for any input that reached it, so the cached-value lookup branch in `GetIndexArgumentDisplay` was unreachable). Simplified `GetIndexArgumentDisplay` and dropped its unused `evaluationCache` parameter. Removed the orphaned `TranslateFailureSentinel` helper as well. - Cached the netstandard/net462 `Regex` instances for the display class cleanup and list-init cleanup pipelines (previously a fresh compiled `Regex` was allocated per failed assertion). On NET, the list-init patterns are now `GeneratedRegex` instead of inline string patterns. - Reduced substring allocations in `RemoveAnonymousTypeWrappers` and `TryMatchListInitPattern` via a new `HasSubstringAt` helper that uses `string.CompareOrdinal` with bounds. - Simplified `IsFuncOrActionType` to call `GetGenericTypeDefinition()` at most once. Added regression tests: - `That_ManuallyConstructedAssign_EvaluatesRhsExactlyOnce` — the main bug. - `That_CompoundAssignmentOnField_AppliesExactlyOnceOnFailure` — exercises the `RequiresSinglePassEvaluation` fix for compound assignments without any other side-effecting nodes. - `That_AssignToFieldOfSideEffectingReceiver_EvaluatesReceiverExactlyOnce` — exercises the `ReplaceSubExpressionsWithConstants` Left rebuild. All 1,157 TestFramework.UnitTests pass on net48 / net8.0 / net9.0. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This PR fixes Assert.That’s single-pass evaluation guarantee for assignment expressions (Expression.Assign, compound assignments, and unary assignment forms) by special-casing how those nodes are evaluated and rebuilt so side effects aren’t accidentally re-executed during diagnostic extraction. It also includes targeted cleanups in the diagnostic rendering pipeline and adds regression tests to lock in the corrected behavior.
Changes:
- Special-cases assignment expression node types in
EvaluateAllSubExpressionsandRequiresSinglePassEvaluationto prevent invalid rebuilds and double execution. - Simplifies index-argument display formatting and removes dead/unreachable helper logic.
- Optimizes diagnostic text cleanup via cached/source-generated regex and reduced substring allocations; adds regression tests for assignment single-evaluation.
Show a summary per file
| File | Description |
|---|---|
| test/UnitTests/TestFramework.UnitTests/Assertions/AssertTests.That.cs | Adds regression tests covering RHS single-evaluation, compound assignment single-application, and side-effecting receiver evaluation. |
| src/TestFramework/TestFramework/Assertions/Assert.That.cs | Implements assignment-aware single-pass evaluation and diagnostic cleanup performance improvements. |
Copilot's findings
Comments suppressed due to low confidence (1)
src/TestFramework/TestFramework/Assertions/Assert.That.cs:278
- Similarly for unary assignment nodes,
EvaluateAllSubExpressions(unaryExpr.Operand, cache)evaluates the operand expression (including property/indexer getters) and then the rebuilt Pre/PostIncrementAssign will evaluate it again as part of its semantics. This can duplicate side effects on writable operands. A dedicated walk that evaluates only receiver/index-argument subexpressions (but not the writable wrapper itself) would avoid double getter execution.
case UnaryExpression unaryExpr when IsAssignmentUnaryNodeType(unaryExpr.NodeType):
EvaluateAllSubExpressions(unaryExpr.Operand, cache);
Expression rebuiltUnaryOperand = ReplaceSubExpressionsWithConstants(unaryExpr.Operand, cache);
UnaryExpression rebuiltUnary = unaryExpr.Update(rebuiltUnaryOperand);
cache[unaryExpr] = Expression.Lambda(rebuiltUnary).Compile().DynamicInvoke();
return;
- Files reviewed: 2/2 changed files
- Comments generated: 1
Evangelink
left a comment
There was a problem hiding this comment.
| # | Dimension | Verdict |
|---|---|---|
| 13 | Test Completeness | 🔴 BLOCKING — 3 tests missing [TestMethod] |
| 10 | Test Isolation | 🟡 MAJOR — static mutable CountingComputeValue.Calls |
✅ 19/21 dimensions clean.
- Test Completeness (#13) —
That_ManuallyConstructedAssign_EvaluatesRhsExactlyOnce,That_CompoundAssignmentOnField_AppliesExactlyOnceOnFailure, andThat_AssignToFieldOfSideEffectingReceiver_EvaluatesReceiverExactlyOnceall lack[TestMethod]. MSTest will never discover them; the three regression scenarios they document are completely unguarded in CI. - Test Isolation (#10) —
CountingComputeValue.Callsis a non-atomic static mutable field. Reset (= 0) and assertion (Should().Be(1)) are not atomic under parallel execution. Prefer an instance-based counter orInterlockedoperations.
The production-side changes (assignment special-casing in EvaluateAllSubExpressions, RequiresSinglePassEvaluation, HasSubstringAt, regex caching) look correct and well-reasoned.
Generated by Expert Code Review (on open) for issue #8363 · ● 19.5M
- EvaluateAllSubExpressions previously walked binaryExpr.Left/unaryExpr.Operand for assignment/compound-assignment nodes. For a property or indexer LHS this invoked the getter once for caching, and the rebuilt compound assignment then invoked it a second time (compound assignments must read the current value). Plain Assign would call the getter once for caching when it shouldn't have been called at all. - Introduce EvaluateAssignmentTargetSubChildren which walks ONLY the writable target's sub-children (receiver / index arguments), never the writable wrapper. This ensures the getter runs exactly once - inside the rebuilt assignment - for compound forms, and zero times for plain Assign. - Cache the assignment result on Left (and on Pre*Assign Operand) so diagnostic rendering doesn't need an extra getter call. - Make CountingComputeValue an instance class with an instance counter so the manually-constructed Assign regression test is robust against any future parallelization. Renaming static field/methods to instance also removes the SA1401 suppression. - Add That_AssignToProperty_DoesNotInvokeGetter and That_CompoundAssignToProperty_InvokesGetterExactlyOnce regression tests that would have caught the original getter double-call. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Fix a single-pass guarantee violation for
Expression.Assign(and compound assignments) plus a few related cleanups inAssert.That. Also addresses leftover review feedback from #8308.Bug:
Expression.Assignran the RHS twiceEmpirical repro on
main:Root cause
The bottom-up evaluator walked Left and Right, cached the RHS value, then went through the generic "rebuild and compile" path. That replaces children with constants — but the Left of an
Assignis a writable target. The rebuiltAssign(Constant, Constant)was invalid, the outertry/catchsentineled it, and the parent expression's later rebuild fell back to invoking the original assignment from scratch — running the RHS side effects a second time.The same hazard affects every compound assignment (
AddAssign,MultiplyAssign, …) and the unary assignment forms (PreIncrementAssign,PostIncrementAssign, …).Fix
Special-case assignment node types in
EvaluateAllSubExpressions:ReplaceSubExpressionsWithConstantsso the writable wrapper (MemberExpression/IndexExpression/ParameterExpression) is preserved while its sub-children become constants. This also fixes double-evaluation of side-effecting Left sub-children (e.g.,provider.GetBox().Value = 42).RequiresSinglePassEvaluationnow also recognizes assignment node types, so a compound assignment whose only side effect is the assignment itself can't slip into the fast path and double-execute.Cleanups
IsSimpleExpression/IsVariableReference—IsSimpleExpressiononly returnedtrueforConstantExpression, which is handled before it's called. The cached-value lookup branch inGetIndexArgumentDisplaywas unreachable. SimplifiedGetIndexArgumentDisplayand dropped its unusedevaluationCacheparameter. Removed the orphanedTranslateFailureSentinelhelper as well.Regexinstances for the display-class cleanup and list-init cleanup pipelines (previously a fresh compiledRegexwas allocated per failed assertion). On NET, list-init patterns are nowGeneratedRegexinstead of inline string patterns.RemoveAnonymousTypeWrappers/TryMatchListInitPatternvia a new non-allocatingHasSubstringAt(input, start, value)helper backed bystring.CompareOrdinal+ bounds.IsFuncOrActionTypeto callGetGenericTypeDefinition()at most once.PR #8308 review feedback
The Copilot AI review left two comments on #8308 that landed after merge:
TestContainer, which treats any public parameterless method as a test (test/Utilities/TestFramework.ForTestingMSTest/TestContainer.cs:6-11). No attribute needed.That_TwoCallsReturningSameMutatedReference_DeduplicatesInDetailsin the merged version. The test exercises the actual scenario its name describes (reference-identity dedup after mutation) regardless of static type.Tests
Added three regression tests:
That_ManuallyConstructedAssign_EvaluatesRhsExactlyOnce— the main bug.That_CompoundAssignmentOnField_AppliesExactlyOnceOnFailure— exercises theRequiresSinglePassEvaluationfix for compound assignments without other side-effecting nodes.That_AssignToFieldOfSideEffectingReceiver_EvaluatesReceiverExactlyOnce— exercises theReplaceSubExpressionsWithConstantsLeft-rebuild fix for side-effecting receivers.All 1,157 TestFramework.UnitTests pass on net48 / net8.0 / net9.0.