Skip to content

Commit cb9334c

Browse files
committed
Deterministic behavior of CancellationOrigin in the case of timeout
1 parent f5a3a7f commit cb9334c

File tree

2 files changed

+73
-10
lines changed

2 files changed

+73
-10
lines changed

src/DotNext.Tests/Threading/CancellationTokenMultiplexerTests.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,4 +137,22 @@ private static void CheckDefaultScope<TScope>(TScope scope)
137137
False(scope.Token.IsCancellationRequested);
138138
Equal(scope.Token, scope.CancellationOrigin);
139139
}
140+
141+
[Fact]
142+
public static async Task CompleteAndCheckConcurrently()
143+
{
144+
var multiplexer = new CancellationTokenMultiplexer();
145+
146+
var scope = multiplexer.CombineAndSetTimeoutLater([]);
147+
var source = new TaskCompletionSource<bool>();
148+
await using var registration = scope.Token.Register(() =>
149+
{
150+
source.SetResult(scope.IsTimedOut);
151+
});
152+
153+
scope.Timeout = TimeSpan.Zero;
154+
True(await source.Task);
155+
156+
await scope.DisposeAsync();
157+
}
140158
}

src/DotNext.Threading/Threading/LinkedCancellationTokenSource.cs

Lines changed: 55 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,43 @@ private bool TrySetCancellationOrigin(CancellationToken token)
7474
return Interlocked.CompareExchange(ref cancellationOrigin.Item1, inlinedToken.Item1, comparand: null) is null;
7575
}
7676

77+
[DebuggerBrowsable(DebuggerBrowsableState.Never)]
78+
private object RawToken
79+
{
80+
get
81+
{
82+
// There is rare race condition that could lead to incorrect detection of the cancellation root:
83+
// the linked token gets canceled in the same time as the timeout happens for this source.
84+
// In this case, the timeout thread resumes the attached callbacks. However, the order
85+
// of these callbacks is not guaranteed. Thus, OnTimeout is not yet called, and cancellationOrigin
86+
// is still null. The resumed thread observes CancellationOrigin == Token. But then,
87+
// the linked token calls Cancel callback that sets cancellationOrigin to the real token.
88+
// In that case, CancellationOrigin != Token. It means that the consumer of CancellationOrigin
89+
// can see two different values when the property getter is called sequentially. This
90+
// is non-deterministic behavior. Currently, nothing we can do with incorrect detection
91+
// of the cancellation root due to absence of the necessary methods in CTS. But we can
92+
// achieve deterministic behavior for CancellationOrigin and IsRootCause properties:
93+
// if cancellation is requested for this source by timeout, switch cancellationOrigin to not-null
94+
// value in getter to prevent concurrent overwrite by the linked token cancellation callback.
95+
object? tokenCopy;
96+
if (CanInlineToken)
97+
{
98+
tokenCopy = IsCancellationRequested
99+
? Interlocked.CompareExchange(ref cancellationOrigin.Item1, this, comparand: null)
100+
: Volatile.Read(in cancellationOrigin.Item1);
101+
102+
tokenCopy ??= this;
103+
}
104+
else if ((tokenCopy = Volatile.Read(in cancellationOrigin.Item1)) is null)
105+
{
106+
object boxedToken = Token;
107+
tokenCopy = Interlocked.CompareExchange(ref cancellationOrigin.Item1, boxedToken, comparand: null) ?? boxedToken;
108+
}
109+
110+
return tokenCopy;
111+
}
112+
}
113+
77114
/// <summary>
78115
/// Gets the token caused cancellation.
79116
/// </summary>
@@ -82,11 +119,13 @@ private bool TrySetCancellationOrigin(CancellationToken token)
82119
/// </remarks>
83120
public CancellationToken CancellationOrigin
84121
{
85-
get => new InlinedToken(Volatile.Read(in cancellationOrigin.Item1)) is { Item1: not null } tokenCopy
86-
? CanInlineToken
87-
? Unsafe.BitCast<InlinedToken, CancellationToken>(tokenCopy)
88-
: Unsafe.Unbox<CancellationToken>(tokenCopy.Item1)
89-
: Token;
122+
get
123+
{
124+
var rawToken = RawToken;
125+
return CanInlineToken
126+
? Unsafe.BitCast<InlinedToken, CancellationToken>(new(rawToken))
127+
: Unsafe.Unbox<CancellationToken>(rawToken);
128+
}
90129

91130
private protected set
92131
{
@@ -99,11 +138,17 @@ private protected set
99138
/// Gets a value indicating that this token source is cancelled by the timeout associated with this source,
100139
/// or by calling <see cref="CancellationTokenSource.Cancel()"/> manually.
101140
/// </summary>
102-
[DebuggerBrowsable(DebuggerBrowsableState.Never)]
103-
internal bool IsRootCause => CanInlineToken
104-
? Volatile.Read(in cancellationOrigin.Item1) is not { } tokenCopy || ReferenceEquals(this, tokenCopy)
105-
: Token == CancellationOrigin;
106-
141+
internal bool IsRootCause
142+
{
143+
get
144+
{
145+
var rawToken = RawToken;
146+
return CanInlineToken
147+
? ReferenceEquals(rawToken, this)
148+
: Unsafe.Unbox<CancellationToken>(rawToken) == Token;
149+
}
150+
}
151+
107152
// This property checks whether the reinterpret cast CancellationToken => CancellationTokenSource
108153
// is safe. If not, just box the token.
109154
internal static bool CanInlineToken => Intrinsics.AreCompatible<CancellationToken, InlinedToken>()

0 commit comments

Comments
 (0)