Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,10 @@ public struct ManualResetValueTaskSourceCore<TResult>
private TResult? _result;
/// <summary>The current version of this value, used to help prevent misuse.</summary>
private short _version;
/// <summary>Whether the current operation has completed.</summary>
private bool _completed;
/// <summary>Typical latency between rearming the source and completing it.</summary>
private long _avgLatencyTicks;
/// <summary>Ticks at time of rearming the source.</summary>
private long _startTicks;
/// <summary>Whether to force continuations to run asynchronously.</summary>
private bool _runContinuationsAsynchronously;
Comment on lines 34 to 42
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

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

This change replaces the _completed bool with two long fields, significantly increasing the size of ManualResetValueTaskSourceCore<TResult> (a public, widely-used struct). Because it’s frequently embedded in other structs/classes and passed by value, the extra 16 bytes can have measurable cache/copying impact. Please include perf data justifying the tradeoff (or consider a smaller representation / feature-gating the latency tracking).

Copilot uses AI. Check for mistakes.

Expand All @@ -57,7 +59,7 @@ public void Reset()
_capturedContext = null;
_error = null;
_result = default;
_completed = false;
_startTicks = Stopwatch.GetTimestamp();
}

/// <summary>Completes with a successful result.</summary>
Expand All @@ -79,24 +81,69 @@ public void SetException(Exception error)
/// <summary>Gets the operation version.</summary>
public short Version => _version;

private bool IsCompleted()
{
if (CheckIsCompleted())
{
return true;
}

// Note: long reads can be torn on 32bit,
// but since this is a statistical approximation it does not matter.
long avgLatency = _avgLatencyTicks;
if (avgLatency == 0 || avgLatency > (Stopwatch.Frequency * 2 / 1000000))
{
return false;
}

// torn read is not a concern here because _startTicks is set as a part of
// rearming the source, before possible publishing to other threads.
long startTicks = _startTicks;
long spinUntil = startTicks + avgLatency * 2;
do
{
if (CheckIsCompleted())
{
return true;
}

Thread.SpinWait(1);
}
while (Stopwatch.GetTimestamp() < spinUntil);
Comment on lines +103 to +112
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

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

The spin loop checks _continuation with a non-volatile read (if (_continuation == sentinel)). If it returns true, GetStatus immediately reads _error to decide between Succeeded/Canceled/Faulted. Without an acquire read of _continuation on that path, it’s possible to observe the sentinel but still read stale _error, causing GetStatus to report the wrong final status. Use Volatile.Read(ref _continuation) / CheckIsCompleted() for the loop’s completion check when deciding to return true.

Copilot uses AI. Check for mistakes.

Comment on lines +91 to +113
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

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

In IsCompleted(), _startTicks is read non-atomically (long start = _startTicks;). On 32-bit, torn long reads can produce a bogus future timestamp and make the subsequent spin loop run far longer than intended. Use an atomic read for _startTicks (e.g., Volatile.Read(ref _startTicks) / Interlocked.Read) and consider bounding spinUntil against the current timestamp to prevent pathological spinning.

Copilot uses AI. Check for mistakes.
return false;
}

private bool CheckIsCompleted()
{
return Volatile.Read(ref _continuation) == (object)ManualResetValueTaskSourceCoreShared.s_sentinel;
}

/// <summary>Gets the status of the operation.</summary>
/// <param name="token">Opaque value that was provided to the <see cref="ValueTask"/>'s constructor.</param>
public ValueTaskSourceStatus GetStatus(short token)
{
ValidateToken(token);

if (!IsCompleted())
{
return ValueTaskSourceStatus.Pending;
}

return
Volatile.Read(ref _continuation) is null || !_completed ? ValueTaskSourceStatus.Pending :
_error is null ? ValueTaskSourceStatus.Succeeded :
_error.SourceException is OperationCanceledException ? ValueTaskSourceStatus.Canceled :
ValueTaskSourceStatus.Faulted;
_error is null ?
ValueTaskSourceStatus.Succeeded :
_error.SourceException is OperationCanceledException ?
ValueTaskSourceStatus.Canceled :
ValueTaskSourceStatus.Faulted;
}

/// <summary>Gets the result of the operation.</summary>
/// <param name="token">Opaque value that was provided to the <see cref="ValueTask"/>'s constructor.</param>
[StackTraceHidden]
public TResult GetResult(short token)
{
if (token != _version || !_completed || _error is not null)
if (token != _version || !CheckIsCompleted() || _error is not null)
{
ThrowForFailedGetResult();
}
Expand Down Expand Up @@ -172,9 +219,9 @@ public void OnCompleted(Action<object?> continuation, object? state, short token
}

// Operation already completed, so we need to queue the supplied callback.
// At this point the storedContinuation should be the sentinal; if it's not, the instance was misused.
// At this point the storedContinuation should be the sentinel; if it's not, the instance was misused.
Debug.Assert(storedContinuation is not null, $"{nameof(storedContinuation)} is null");
if (!ReferenceEquals(storedContinuation, ManualResetValueTaskSourceCoreShared.s_sentinel))
if (storedContinuation != (object)ManualResetValueTaskSourceCoreShared.s_sentinel)
{
ThrowHelper.ThrowInvalidOperationException();
}
Expand Down Expand Up @@ -209,11 +256,19 @@ private void ValidateToken(short token)
/// <summary>Signals that the operation has completed. Invoked after the result or error has been set.</summary>
private void SignalCompletion()
{
if (_completed)
if (CheckIsCompleted())
{
ThrowHelper.ThrowInvalidOperationException();
}
_completed = true;

// torn read is not a concern here because _startTicks is set as a part of
// rearming the source, before possible publishing to other threads.
long startTicks = _startTicks;
if (startTicks != 0)
{
long latencyTicks = Stopwatch.GetTimestamp() - startTicks;
_avgLatencyTicks = (_avgLatencyTicks + latencyTicks) / 2;
}

Action<object?>? continuation =
Volatile.Read(ref _continuation) ??
Expand All @@ -225,6 +280,7 @@ private void SignalCompletion()
_continuationState = null;
object? context = _capturedContext;
_capturedContext = null;
_continuation = ManualResetValueTaskSourceCoreShared.s_sentinel;

if (context is null)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ System.Threading.Channel&lt;T&gt;</PackageDescription>
<ProjectReference Include="$(LibrariesProjectRoot)System.Memory\src\System.Memory.csproj" />
<ProjectReference Include="$(LibrariesProjectRoot)System.Runtime\src\System.Runtime.csproj" />
<ProjectReference Include="$(LibrariesProjectRoot)System.Threading\src\System.Threading.csproj" />
<ProjectReference Include="$(LibrariesProjectRoot)System.Threading.Thread\src\System.Threading.Thread.csproj" />
<ProjectReference Include="$(LibrariesProjectRoot)System.Threading.ThreadPool\src\System.Threading.ThreadPool.csproj" />
Comment on lines 56 to 60
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

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

Using Thread.SpinWait here required adding a new System.Threading.Thread project reference. If the only need is an opaque spin to prevent load-hoisting, consider using SpinWait (struct) instead, which avoids pulling in the Thread assembly dependency for System.Threading.Channels’ netcoreapp build.

Copilot uses AI. Check for mistakes.
</ItemGroup>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using System.Collections.Generic;
using System.Diagnostics;
using System.Runtime.ExceptionServices;
using System.Threading;
using System.Threading.Tasks;
using System.Threading.Tasks.Sources;

Expand Down Expand Up @@ -92,6 +93,11 @@ private volatile
/// </remarks>
private protected short _currentId;

/// <summary>Typical latency between rearming the source and completing it.</summary>
private protected long _avgLatencyTicks;
/// <summary>Ticks at time of rearming the source.</summary>
private protected long _startTicks;

/// <summary>Initializes the interactor.</summary>
/// <param name="runContinuationsAsynchronously">true if continuations should be forced to run asynchronously; otherwise, false.</param>
/// <param name="cancellationToken">The cancellation token used to cancel the operation.</param>
Expand Down Expand Up @@ -139,7 +145,43 @@ private CancellationToken CancellationToken
#endif

/// <summary>Gets whether the operation has completed.</summary>
internal bool IsCompleted => ReferenceEquals(_continuation, s_completedSentinel);
private protected bool IsCompleted()
{
if (CheckIsCompleted())
{
return true;
}

// Note: long reads can be torn on 32bit,
// but since this is a statistical approximation it does not matter.
long avgLatency = _avgLatencyTicks;
if (avgLatency == 0 || avgLatency > (Stopwatch.Frequency * 2 / 1000000))
{
return false;
}

// torn read is not a concern here because _startTicks is set as a part of
// rearming the source, before possible publishing to other threads.
long startTicks = _startTicks;
long spinUntil = startTicks + avgLatency * 2;
do
{
if (CheckIsCompleted())
{
return true;
}

Thread.SpinWait(1);
}
while (Stopwatch.GetTimestamp() < spinUntil);
Comment on lines +155 to +176
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

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

In IsCompleted(), the spin loop reads _startTicks and later compares Stopwatch.GetTimestamp() against spinUntil computed from it. On 32-bit, non-volatile long reads can be torn; a torn _startTicks can appear far in the future and cause the loop to spin for an unbounded time. Use an atomic read for _startTicks (e.g., Volatile.Read(ref _startTicks) or Interlocked.Read) and/or guard against future/overflow values before spinning.

Copilot uses AI. Check for mistakes.

return false;
}

private protected bool CheckIsCompleted()
{
return Volatile.Read(ref _continuation) == (object)s_completedSentinel;
}

/// <summary>Completes the operation with a failed state and the specified error.</summary>
/// <param name="exception">The error.</param>
Expand Down Expand Up @@ -203,6 +245,15 @@ private protected void SignalCompletion()
// be a nop, as its TrySetCanceled will return false and the callback will exit without doing further work.
Unregister(_cancellationRegistration);

// torn read is not a concern here because _startTicks is set as a part of
// rearming the source, before possible publishing to other threads.
long startTicks = _startTicks;
if (startTicks != 0)
{
long latencyTicks = Stopwatch.GetTimestamp() - startTicks;
_avgLatencyTicks = (_avgLatencyTicks + latencyTicks) / 2;
}

// NB: Assigning _continuation happens after assigning continuation dependencies (_capturedContext, _continuationState)
// and effectively "commits" the entire continuation state as ready for invocation.
// We must read _continuation before accessing its dependencies.
Expand Down Expand Up @@ -364,7 +415,7 @@ public void OnCompleted(Action<object?> continuation, object? state, short token
// If the set failed because there's already a delegate in _continuation, but that delegate is
// something other than s_completedSentinel, something went wrong, which should only happen if
// the instance was erroneously used, likely to hook up multiple continuations.
Debug.Assert(IsCompleted, $"Expected IsCompleted");
Debug.Assert(CheckIsCompleted(), $"Expected IsCompleted");
if (!ReferenceEquals(prevContinuation, s_completedSentinel))
{
Debug.Assert(prevContinuation != s_availableSentinel, "Continuation was the available sentinel.");
Expand Down Expand Up @@ -444,11 +495,17 @@ public ValueTaskSourceStatus GetStatus(short token)
ThrowIncorrectCurrentIdException();
}

if (!IsCompleted())
{
return ValueTaskSourceStatus.Pending;
}

return
!IsCompleted ? ValueTaskSourceStatus.Pending :
_error is null ? ValueTaskSourceStatus.Succeeded :
_error.SourceException is OperationCanceledException ? ValueTaskSourceStatus.Canceled :
ValueTaskSourceStatus.Faulted;
_error is null ?
ValueTaskSourceStatus.Succeeded :
_error.SourceException is OperationCanceledException ?
ValueTaskSourceStatus.Canceled :
ValueTaskSourceStatus.Faulted;
}

/// <summary>Gets the result of the operation.</summary>
Expand All @@ -460,7 +517,7 @@ void IValueTaskSource.GetResult(short token)
ThrowIncorrectCurrentIdException();
}

if (!IsCompleted)
if (!CheckIsCompleted())
{
ThrowIncompleteOperationException();
}
Expand Down Expand Up @@ -504,7 +561,7 @@ public TResult GetResult(short token)
ThrowIncorrectCurrentIdException();
}

if (!IsCompleted)
if (!CheckIsCompleted())
{
ThrowIncompleteOperationException();
}
Expand Down Expand Up @@ -533,6 +590,7 @@ public bool TryOwnAndReset()
_result = default;
_error = null;
_capturedContext = null;
_startTicks = Stopwatch.GetTimestamp();
return true;
}

Expand Down
Loading