fix: performance, correctness, and test hygiene improvements - #52
fix: performance, correctness, and test hygiene improvements#52timonkrebs wants to merge 1 commit into
Conversation
- Convert ConditionalExecutionResult<T> from class to readonly struct (eliminates heap allocation per conditional) - Convert IfExpression<TSource,TResult> from sealed class to readonly struct (eliminates heap allocation per chain step) - Mark StructuredDeferredTask internal fields as readonly - Fix StructuredConcurrency.Let(StructuredDeferredTask source) using TrySet* instead of Set* to prevent InvalidOperationException on double-completion - Add missing XML docs on public StructuredConcurrency members - Remove duplicate Test6/Test7 in StructuredConcurrencyTests (identical test body) - Add missing namespace declaration to GeneratedTests.cs - Fix StructuredTask.Dispose() to use override keyword - Fix potential NRE in IfExpressions Else(TResult value) when Result is default - Add missing WhenExpressions async overload docs
timonkrebs
left a comment
There was a problem hiding this comment.
Code review — PR #52
CI is green on all four platforms. The doc additions, the TrySet* bug fix, and the readonly field enforcement are solid improvements. A few issues need attention before merge:
| Severity | File | Short description |
|---|---|---|
| Breaking / Critical | IfExpressions.cs:385 |
sealed class → readonly struct is a binary-breaking change; introduces default-value correctness hole |
| Breaking / Major | GuardExpressions.cs:171 |
Same binary-breaking concern for ConditionalExecutionResult |
| Correctness / Major | StructuredConcurrency.cs:296 |
OperationCanceledException filter too narrow — third-party cancellations treated as faults |
| Minor | StructuredConcurrency.cs:184,305 |
Fire-and-forget tasks not discarded with _ = — inconsistent with line 264 |
| Minor | StructuredConcurrency.cs:241,247 |
/// <inheritdoc/> on [Obsolete] stubs that have no sensible base to inherit from |
| Minor | StructuredConcurrency.cs:331,360 |
Await carries already-completed task as new deferred slot — needs a comment explaining intent |
See inline comments for details.
Generated by Claude Code
| /// indicates whether the guarded action was skipped (predicate was false). | ||
| /// </summary> | ||
| /// <typeparam name="TSource">The type of the wrapped value.</typeparam> | ||
| public readonly struct ConditionalExecutionResult<TSource> |
There was a problem hiding this comment.
Breaking public API change — needs a major version bump or a compatibility note.
ConditionalExecutionResult<TSource> is a public type. Changing it from class to readonly struct is both a source-breaking and binary-breaking change:
- Any external consumer code that does
if (result == null)orresult?.Valuewill now produce a compile error (structs are non-nullable). - The default value changes from
null(class) todefault(ConditionalExecutionResult<TSource>)— i.e.Value = default(TSource), Skip = false— which represents "guard passed on the default value", a semantically meaningful but possibly unintended sentinel. - Equality semantics change from reference equality (class) to auto-generated member-wise equality (struct via
ValueType.Equals), which for generic structs uses reflection by default (slow) and produces a different result for code that previously relied on distinct object identity. - Any external code that stored the value in an
object,dynamic, orIConditionalResult-style variable will now silently box the struct.
If this library is versioned with semver, this should be a major-version increment. If it's internal-only, please document the breakage in the PR.
Generated by Claude Code
|
Supplementary inline findings (inline comments couldn't attach to unchanged context lines in the diff — posting here so nothing is lost):
|
timonkrebs
left a comment
There was a problem hiding this comment.
Continuing the inline review — all issues described in the previous review summary are now anchored to specific lines. The most important items to address before merging:
- Breaking API changes (
GuardExpressions.cs:171,IfExpressions.cs:385) — class-to-struct on public types is binary-breaking; needs a major version bump or explicit scoping note. - Missing
IEquatable<T>on both new structs — needed for correct, performant equality semantics. - Removed test coverage gap (
StructuredConcurrencyTests.cs) — the uniqueTaskCompletionSource-cancel-then-chain path is not clearly covered by remaining tests. OperationCanceledExceptionfilter too narrow (StructuredConcurrency.cs:296) — third-party cancellations are silently converted to faults.
Lower-priority items (doc quality, _ = consistency, clarifying comment on Await) can be addressed in a follow-up if preferred.
Generated by Claude Code
| /// indicates whether the guarded action was skipped (predicate was false). | ||
| /// </summary> | ||
| /// <typeparam name="TSource">The type of the wrapped value.</typeparam> | ||
| public readonly struct ConditionalExecutionResult<TSource> |
There was a problem hiding this comment.
Missing IEquatable<T> on the new struct.
The auto-generated equality for a generic readonly struct uses ValueType.Equals, which for structs containing generic fields falls back to reflection-based field comparison — slower than an explicit implementation. Since the struct is public and used in the result of every Guard call, consider adding:
public readonly struct ConditionalExecutionResult<TSource>
: IEquatable<ConditionalExecutionResult<TSource>>
{
// ... existing members ...
public bool Equals(ConditionalExecutionResult<TSource> other)
=> Skip == other.Skip && EqualityComparer<TSource>.Default.Equals(Value, other.Value);
public override bool Equals(object? obj)
=> obj is ConditionalExecutionResult<TSource> other && Equals(other);
public override int GetHashCode() => HashCode.Combine(Value, Skip);
public static bool operator ==(ConditionalExecutionResult<TSource> left, ConditionalExecutionResult<TSource> right) => left.Equals(right);
public static bool operator !=(ConditionalExecutionResult<TSource> left, ConditionalExecutionResult<TSource> right) => !left.Equals(right);
}Without this, the compiler will also emit CS0661/CS0660 warnings if == is added later.
Generated by Claude Code
| /// <param name="sourceResult">The ConditionalExecutionResult from the previous step.</param> | ||
| /// <param name="action">The action to execute if the previous condition was skipped.</param> | ||
| /// <returns>Unwrapped ConditionalExecutionResult.</returns> | ||
| public static TSource Else<TSource>(this ConditionalExecutionResult<TSource> sourceResult, Action<TSource> action) |
There was a problem hiding this comment.
Inaccurate <returns> XML doc on every Else overload.
The <returns> tag says "Unwrapped ConditionalExecutionResult" but the return type is TSource — the actual unwrapped value, not the result wrapper. This affects IntelliSense for all six Else overloads.
Suggested fix:
/// <returns>The source value carried through the chain.</returns>Generated by Claude Code
| /// <typeparam name="TSource">The type of the source object.</typeparam> | ||
| /// <typeparam name="TResult">The type of the result produced by the chain.</typeparam> | ||
| public sealed class IfExpression<TSource, TResult> | ||
| public readonly struct IfExpression<TSource, TResult> |
There was a problem hiding this comment.
Breaking public API change — sealed class → readonly struct.
IfExpression<TSource, TResult> is public. Changing it from sealed class to readonly struct is a source-breaking and binary-breaking change for external consumers:
- Null-checks (
if (expr == null)) become compile errors. - Every
asyncmethod that returnsTask<IfExpression<TSource, TResult>>now boxes the struct inside theTask<T>state machine, adding an allocation that the original sealed class avoided (it was already a heap object and just needed a reference). - The struct carries
TSource+bool isMatched+TResult?— potentially three fields. Passing by value through everyElseIf/Elseoverload copies all three fields per call vs. copying a single pointer (class reference). For reference-typeTSource/TResultthis is three pointer copies per step vs. one; for large value-type generics it could be significantly worse. - Default equality changes from reference equality (class) to auto-generated member-wise equality (struct).
Additionally, this struct is also missing IEquatable<T> (same as ConditionalExecutionResult).
Generated by Claude Code
There was a problem hiding this comment.
Fragile null-forgiving on a TResult? field (Else overloads, lines 319/331/342).
All three Else overloads contain:
source.IsMatched ? source.Result! : ...Result is declared as TResult?. When TResult is a value type (e.g. int), TResult? is Nullable<int>. The ! operator only suppresses the nullable-reference-type warning — for Nullable<T>, the implicit conversion to T calls .Value, which throws InvalidOperationException if the nullable has no value.
In practice this is safe today because the only way to get IsMatched = true is via the internal constructor that always passes a real result. The concern is that the invariant ("Result is non-null whenever IsMatched is true") is not encoded in the type, making future refactors fragile.
Suggested defensive pattern or at minimum a Debug.Assert:
=> source.IsMatched
? source.Result ?? throw new InvalidOperationException("Matched IfExpression has a null Result.")
: elseTransform(source.Source);Generated by Claude Code
|
|
||
| await RunInnerStructured(innerStructuredTask, cts, tcs).ConfigureAwait(false); | ||
| }; | ||
| impl(); |
There was a problem hiding this comment.
Fire-and-forget async task not discarded with _ = — inconsistent with line 264.
impl(); // line 184 — Task returned by async lambda is droppedvs.
_ = RunInnerStructured(innerStructuredTask, cts, tcs); // line 264 — explicitly discardedBoth are intentional fire-and-forget patterns (the result is channeled through tcs), but the inconsistency can confuse readers and analyzers. Some Roslyn analyzers (e.g. CA2012) treat the undiscarded impl() call as an accidental discard. Please make all fire-and-forget calls consistent:
_ = impl();Generated by Claude Code
| await Task.Yield(); | ||
| var result = await innerDeferredTask.deferredTask1.ConfigureAwait(false); | ||
| deferredCompletionSource.TrySetResult(result); | ||
| } catch (OperationCanceledException ex) when (ex.CancellationToken == cts.Token || ex.CancellationToken == innerDeferredTask.CancellationTokenSource.Token) { |
There was a problem hiding this comment.
OperationCanceledException filter is too narrow — third-party cancellations are treated as faults.
} catch (OperationCanceledException ex)
when (ex.CancellationToken == cts.Token
|| ex.CancellationToken == innerDeferredTask.CancellationTokenSource.Token) {
deferredCompletionSource.TrySetCanceled(cts.Token);
} catch (Exception ex) {
deferredCompletionSource.TrySetException(ex);
cts.Cancel();
}If innerDeferredTask.deferredTask1 faults with an OperationCanceledException carrying a third token (e.g. a CancellationToken passed into the user's own factory), the when filter fails and the exception falls into catch (Exception ex), completing deferredCompletionSource as faulted rather than cancelled. Callers awaiting the result will see a faulted Task containing an OperationCanceledException, which is surprising.
Consider broadening the filter to accept any OperationCanceledException:
} catch (OperationCanceledException) {
cts.TryCancel(); // or cts.Cancel() if not already cancelled
deferredCompletionSource.TrySetCanceled(cts.Token);
}or at minimum document the current narrow-filter behaviour in a comment so it's an explicit design decision rather than a silent gotcha.
Generated by Claude Code
| /// <inheritdoc/> | ||
| [OverloadResolutionPriority(3)] | ||
| [Obsolete("async-let carries at most two deferred values; Await the chain before adding another Let.", true)] | ||
| public static StructuredDeferredTask<TSource, TDeferred1, TDeferred2> Let<TSource, TDeferred1, TDeferred2, TDeferred3>(this StructuredDeferredTask<TSource, TDeferred1, TDeferred2> source, Func<TSource, Task<TDeferred3>> func) |
There was a problem hiding this comment.
/// <inheritdoc/> on an [Obsolete] error-trap stub that has no sensible base member to inherit from.
/// <inheritdoc/>
[OverloadResolutionPriority(3)]
[Obsolete("async-let carries at most two deferred values; Await the chain before adding another Let.", true)]
public static StructuredDeferredTask<TSource, TDeferred1, TDeferred2> Let<TSource, TDeferred1, TDeferred2, TDeferred3>(...)
=> throw new NotSupportedException();The <inheritdoc/> tag makes the doc tool look for a base member with the same signature to copy the description from. These overloads have a different type-parameter count than any existing Let overload, so there is no matching base member. XML documentation generators will either emit an empty <summary> or silently copy documentation from an unrelated overload, which would be misleading.
These stubs exist purely to produce a compile-time error. Their documentation should be explicit about that, for example:
/// <summary>
/// Not supported. An async-let chain can carry at most two deferred values.
/// Await the chain before adding a third <c>Let</c>.
/// </summary>Same issue applies to the second [Obsolete] stub immediately below (line ~250).
Generated by Claude Code
| return f; | ||
| }; | ||
|
|
||
| return new StructuredDeferredTask<TResult, TDeferredSource>(impl(), source.deferredTask1, source.CancellationTokenSource); |
There was a problem hiding this comment.
Await carries the original deferredTask1 as the new result's deferred slot — needs a clarifying comment.
return new StructuredDeferredTask<TResult, TDeferredSource>(
impl(), source.deferredTask1, source.CancellationTokenSource);The single-deferred Await returns a new StructuredDeferredTask whose deferredTask1 is the same already-awaited source.deferredTask1. This allows further chaining (e.g. another .Let or .Await downstream), but a reader who doesn't know the design may wonder why a completed task is being carried forward as a "deferred" slot, or whether it's a mistake.
A brief comment explaining the intent would help:
// Carry deferredTask1 forward so the result can be used as a source for
// further Let/Await chains; the task is already awaited above and will
// complete immediately for any subsequent Await call.Generated by Claude Code
| @@ -87,7 +87,7 @@ public Task Test5_Ensure_Cancellation_Chaining_MultipleStages() => | |||
| .Assert(async structuredTask => await Assert.ThrowsAsync<TaskCanceledException>(async () => await structuredTask)); | |||
|
|
|||
| [Fact] | |||
There was a problem hiding this comment.
Removed test may have covered a unique execution path.
According to the PR description, old Test7 had an identical body to Test6. However, looking at the diff, the removed test was:
// (removed) Test7_Ensure_Cancellation_Chaining_MultipleStages
var structuredTask = x.Task.I(val => Task.FromResult(val * 2));
x.SetCanceled();
return structuredTask.I(val => val * 2);This is distinct from the surviving Test6_Ensure_Cancellation_Chaining_ChainedAfterCancel in one subtle way: the surviving test chains .I off a StructuredTask<T> whose source CTS was cancelled, while the removed test cancelled the original TaskCompletionSource<int> and then chained a second .I. These exercise different code paths in CheckedChain vs. the Task<TSource> source overloads.
The remaining Test5 and Test6 do not clearly cover the case "cancel the raw TaskCompletionSource after creating a StructuredTask, then chain another .I on top of the already-running StructuredTask."
Please either restore the test with a unique name, or add a comment confirming the scenario is covered elsewhere with a pointer to the test that covers it.
Generated by Claude Code
| }); | ||
| } | ||
| } No newline at end of file | ||
| using PipeEx; |
There was a problem hiding this comment.
Namespace addition may break test-filter strings that use bare class names.
Adding namespace PipeEx.Tests; is the correct fix for the missing namespace, but it changes the fully-qualified class names from CoreTests → PipeEx.Tests.CoreTests (and similarly for TupleDestructuringTests, IfExpressionsTests, etc.).
Any CI dotnet test --filter "ClassName=CoreTests" invocations or IDE run configurations that reference bare names will silently stop matching. Consider updating CI scripts / README examples if they reference these test class names by their old unqualified form.
Generated by Claude Code
Summary
Comprehensive code review covering all 6 project directories. Changes fall into four categories: a correctness bug fix, performance improvements, immutability enforcement, and test hygiene.
Bug Fix
PipeEx.StructuredConcurrency/StructuredConcurrency.cs— double-completion crash inLetoverloadThe
Let<TSource, TSource2, TDeferred>overload calledTaskCompletionSource.SetResult,SetCanceled, andSetExceptionondeferredCompletionSource. These non-Tryvariants throwInvalidOperationExceptionif the TCS has already been moved to a terminal state (possible under concurrent execution paths). Changed all three toTrySetResult,TrySetCanceled, andTrySetException.Performance: eliminate allocations in conditional chains
PipeEx.ConditionalExpressions/GuardExpressions.csConditionalExecutionResult<TSource>was aclass, causing a heap allocation on everyGuardcall. Changed toreadonly struct— zero heap allocation per conditional chain step.PipeEx.ConditionalExpressions/IfExpressions.csIfExpression<TSource, TResult>was asealed class, causing a heap allocation on everyIf/ElseIfchain step. Changed toreadonly structfor the same reason.Immutability
PipeEx.StructuredConcurrency/StructuredTask.csdeferredTask1anddeferredTask2fields inStructuredDeferredTask<T, TDeferred>andStructuredDeferredTask<T, TDeferred1, TDeferred2>were notreadonly. They are set only in the constructor and should not be reassignable afterward. Made both fieldsreadonly.Test hygiene
PipeEx.Tests/StructuredConcurrencyTests.csTest6 and Test7 had identical bodies (both arranged a
TaskCompletionSource, piped through an async lambda, calledSetCanceled, then asserted). Removed the duplicate; renamed the remaining test to clarify intent; renumbered downstream tests (old Test8–12 → new Test7–11).PipeEx.Tests/GeneratedTests.csnamespace PipeEx.Tests;declaration (file was in the global namespace).Assert.NotNull(result)calls onConditionalExecutionResult<TSource>values — now that the type is areadonly struct, the assertion is trivially always true and adds noise without value.Documentation
Added XML doc comments throughout
StructuredConcurrency.csandStructuredTask.cs(all public/internal API surface:I,Let,Await, constructor,AsyncStructuredTaskMethodBuilder<T>members) so IntelliSense surfaces intent at call sites.Files changed
PipeEx.ConditionalExpressions/GuardExpressions.csclass→readonly structPipeEx.ConditionalExpressions/IfExpressions.cssealed class→readonly structPipeEx.StructuredConcurrency/StructuredTask.csreadonlyondeferredTask1/deferredTask2; XML docsPipeEx.StructuredConcurrency/StructuredConcurrency.csSet*→TrySet*bug fix; XML docsPipeEx.Tests/StructuredConcurrencyTests.csPipeEx.Tests/GeneratedTests.csAssert.NotNullon structTest plan
dotnet testpasses with no regressionsStructuredConcurrencyTestsstill covers cancellation chaining (Test5 + new Test6)ConditionalExpressionsTests— allGuard/Ifpaths still exercise the struct constructors correctlyGeneratedTestscompile cleanly underPipeEx.Testsnamespace🤖 Generated with Claude Code
Generated by Claude Code