mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-28 22:55:49 +08:00
## Description Five small changes to the per-request server path, found in the CPU and allocation profiles of the o6 read benchmark (`AsyncCustomNodeManager`, Server GC, 10 clients × 100 Reads in flight) on master3a31473cf. Each change is its own commit and can be reviewed and reverted on its own. The channel gate (about 40 % of waits) is not touched; that is A1. ### Results Paired A/B runs against master (0e40e1229), alternating builds per round, 5 rounds, medians (min..max). The machine is shared with other sessions: allocated bytes per request are deterministic; CPU per request is best read from the runs pinned to 4 cores. | Scenario | master | this PR | Change | |---|---|---|---| | o6read 10 × 100, server pinned to 4 cores: CPU / request | 39.1 µs (35.9..39.8) | 36.9 µs (35.5..37.6) | **−5.6 %** | | same: allocated / request | 6,198 B | 5,224 B | **−15.7 %** | | same: req/s | 98.7k (95.4..108.5k) | 104.6k (97.6..105.9k) | +5.9 % | | o6read 10 × 100, 24 cores: req/s | 183.7k (173.3..198.0k) | 189.8k (178.9..197.5k) | +3.4 % | | same: allocated / request | 6,500 B | 5,578 B | −14.2 % | | o6read 10 × 100 Basic256Sha256, 24 cores: req/s | 159.8k (149.4..178.7k) | 164.2k (156.7..165.3k) | +2.7 % | | same: allocated / request | 6,590 B | 5,694 B | −13.6 % | | `read --nodes 1`, 20 sessions: req/s | 155.3k (141.6..157.6k) | 163.8k (163.4..166.5k) | +5.5 % | | same: allocated / request | 5,548 B | 4,689 B | −15.5 % | | `read --nodes 100`: allocated / request | 74.4 KB | 70.1 KB | −5.8 % | | `browse`: allocated / request | 39.8 KB | 35.0 KB | −12.2 % | | `browse`: lock contentions / 1000 requests | 474 | 367 | −23 % | At 24 cores CPU per request stays within noise, because the time these changes free now waits at the channel gate (see below). Per item, pinned to 4 cores (5 rounds; CPU deltas of one item are within run-to-run noise, allocation deltas are not): | Item | CPU / request | Allocated / request | |---|---|---| | Q1 system context | +1.8 % | −1.7 % | | Q2 request table | −4.3 % | −1.4 % | | Q3 request timeouts | −2.8 % | −2.2 % | | Q4 namespace metadata | −3.5 % | ±0 | | Q5 read-path allocations | −2.4 % | −10.9 % | Sampled thread time at 24 cores (cpu trace, first `Opc.Ua` frame above the wait or copy): | Frame | master | this PR | |---|---|---| | `ServerSystemContext.Copy` (incl. `MemberwiseClone`) | 12.4 % | 0.1 % | | `RequestManager` (`ConcurrentDictionary` inside `EnterValidationScope`, scope `Dispose`, `RequestReceived`, `CompleteRequest`) | 7.1 % | 0 | | `RequestLifetime` (CTS registration spin, ctor/`RegisterExternal`/`DisposeResources`) | 4.8 % | 0.2 % | | `NamespaceMetadataRegistry.GetAsync` lock | 4.0 % | 0 | | `RuntimeHelpers.InitializeArray` (`ReadValidationAttributes`) | 2.2 % | 0 | | Channel gate (`ChannelGate+AwaitEntryAsync`, inclusive) | 31 % | 46 % | ## Changes **Q1: one system context copy per request and node manager.** `AsyncCustomNodeManager`'s service methods (Read, Write, Browse, TranslateBrowsePaths, Call, HistoryRead/Update, monitored item and event services) use the per-request copy from `OperationContext.GetSystemContext` instead of copying the node manager's context again. The per-request cache keeps the first node manager's copy without an extra object and up to eight more, so a request that touches several node managers no longer evicts the other one's copy. `ServerSystemContext` copies itself with a copy constructor instead of `MemberwiseClone` (which allocates through a runtime call) unless it is a derived type; `SessionSystemContext` gains a protected copy constructor. Copies per Read went from about three to one. **Q2: lock-free request registration in `RequestManager`.** #4742 took the server-wide lock off the request path, but the two `ConcurrentDictionary`s for executing requests and open validation scopes stayed the main waiting point after the channel gate (four writes per request from every worker). `RequestTable<T>` places an entry with a compare-and-swap into a free slot near a scrambled hash of its key, and the entry remembers its slot so completion is one more compare-and-swap; only when every probed slot is taken does an entry go to an overflow set under a lock. Drains, Cancel, Session close, the timeout sweep and `Dispose` read the slots one after the other, which keeps the existing ordering arguments (registration fence vs. Session/Cancel marks, drain count published before the snapshot). Sharding the dictionaries over per-processor locks was tried first and only moved about 5 % of waits into the shard locks. **Q3: no timer or shared-token registrations per request.** `RequestLifetime`'s timeout constructor, used by the endpoint for every request, no longer creates a `Timer` and no longer registers with the server's request-queue token and the transport token, which every worker registered with and so contended on. The deadline and the two tokens are watched by `RequestDeadlineMonitor`: one sweep every 20 ms over intrusive lists sharded by processor, running only while requests are monitored. The `params` constructor keeps its synchronous registrations. **Q4: namespace metadata lookups without a lock.** `NamespaceMetadataRegistry` reads its caches (found and missing, by URI and now also by index) without its lock; only filling and invalidating take it. The generation moves before the caches are cleared, so a lookup racing an invalidation never reports a stale miss. **Q5: fewer per-call allocations on the read path.** `ReadValidationAttributes` fills its attribute-id array element by element instead of with an array initializer (`RuntimeHelpers.InitializeArray` per call); single-node Read and Write requests skip the per-request attribute cache and validate with `permissionsOnly` (the same three attributes, read once); `ReadAsync` allocates the list of nodes to validate only when needed. (Review: a shared static attribute array, a per-thread `Variant[]` scratch array and a per-thread Read node cache were dropped again because they crossed virtual calls; the measurements below were taken with them, so Q5's allocation saving is a little smaller than shown.) ## Behaviour notes - **Request timeouts (Q3):** a `TimeoutHint` now fires up to 20 ms late (the sweep period), and cancelling the server's or the transport's token reaches the request's `CancellationToken` up to 20 ms later instead of synchronously. A token that is already cancelled cancels the request at once, and `TryCancel` checks the tokens first, so a token cancelled before an explicit Cancel still owns the terminal state. The exactly-once terminal state of #4744 is unchanged: the sweep claims it through the same compare-and-swap. - **System contexts (Q1):** a custom node manager that changed fields of the context it got in a service call now changes the copy shared by that request's other calls into the same node manager; it never reaches other requests or the node manager's own `SystemContext`. - **Request ids (Q2):** `RequestManager.RequestReceived` no longer throws for a different `OperationContext` with a request id that is still registered. Requests are tracked by their context; ids come from a server-wide counter. ## Testing - New: `ServerSystemContextCopyTests` (every field copied, derived types kept), `AsyncCustomNodeManagerTests` (one copy per node manager and request, two managers in one request, not shared across requests; single- vs multi-node Read verdicts identical incl. a node read twice), `RequestTableTests` (idempotent add, exactly-once remove, overflow, concurrent churn while enumerating; drain racing completion on many threads; Session close and Cancel by handle exactly once while others register), `RequestDeadlineMonitorTests` (timeout precision with a fake clock, token propagation, ownership vs. explicit Cancel, no leaked entries, sweep/cancel/complete race), `NamespaceMetadataRegistryTests` (cached miss by index invalidated, lookups racing `Invalidate`, permission changes visible through the cached node). - Run locally (net10.0): Opc.Ua.Types.Tests, Opc.Ua.Core.Tests (Stack), Opc.Ua.Server.Tests, Opc.Ua.Sessions.Tests, Opc.Ua.Subscriptions.Tests, Opc.Ua.Redundancy.Server.Tests, Opc.Ua.Stress.Tests Contract + Integration. net48 builds. - Measured with `perf/ServerLoadHarness` scenarios `o6read` (o6 harness, not part of this PR), `read`, `browse`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
506 lines
21 KiB
C#
506 lines
21 KiB
C#
/* ========================================================================
|
|
* Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
|
|
*
|
|
* OPC Foundation MIT License 1.00
|
|
*
|
|
* Permission is hereby granted, free of charge, to any person
|
|
* obtaining a copy of this software and associated documentation
|
|
* files (the "Software"), to deal in the Software without
|
|
* restriction, including without limitation the rights to use,
|
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
* copies of the Software, and to permit persons to whom the
|
|
* Software is furnished to do so, subject to the following
|
|
* conditions:
|
|
*
|
|
* The above copyright notice and this permission notice shall be
|
|
* included in all copies or substantial portions of the Software.
|
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
|
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
|
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
|
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
|
* OTHER DEALINGS IN THE SOFTWARE.
|
|
*
|
|
* The complete license agreement can be found here:
|
|
* http://opcfoundation.org/License/MIT/1.00/
|
|
* ======================================================================*/
|
|
|
|
// CA2000: test code; many disposables are ownership-transferred to test fixtures or short-lived,
|
|
// making CA2000 noisy without a real leak risk. Disabled file-level for the suite.
|
|
#pragma warning disable CA2000
|
|
using System;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using NUnit.Framework;
|
|
|
|
namespace Opc.Ua.Core.Tests.Stack.Server
|
|
{
|
|
[TestFixture]
|
|
[Category("Server")]
|
|
[SetCulture("en-us")]
|
|
[SetUICulture("en-us")]
|
|
[Parallelizable]
|
|
public class RequestLifetimeTests
|
|
{
|
|
[Test]
|
|
public void Constructor_Default_CreatesInstance()
|
|
{
|
|
using var lifetime = new RequestLifetime();
|
|
|
|
Assert.That(lifetime.CancellationToken.IsCancellationRequested, Is.False);
|
|
Assert.That(lifetime.StatusCode, Is.EqualTo(StatusCodes.Good));
|
|
}
|
|
|
|
[Test]
|
|
public void Constructor_WithExternalToken_CreatesLinkedTokenSource()
|
|
{
|
|
using var cts = new CancellationTokenSource();
|
|
using var lifetime = new RequestLifetime(cts.Token);
|
|
|
|
Assert.That(lifetime.CancellationToken.IsCancellationRequested, Is.False);
|
|
|
|
cts.Cancel();
|
|
|
|
Assert.That(lifetime.CancellationToken.IsCancellationRequested, Is.True);
|
|
Assert.That(lifetime.StatusCode, Is.EqualTo(StatusCodes.Good));
|
|
}
|
|
|
|
[Test]
|
|
public void TryCancel_WhenNotCancelled_ReturnsTrue_SetsStatusCode_CancelsToken()
|
|
{
|
|
using var lifetime = new RequestLifetime();
|
|
|
|
bool result = lifetime.TryCancel(StatusCodes.BadTimeout);
|
|
|
|
Assert.That(result, Is.True);
|
|
Assert.That(lifetime.CancellationToken.IsCancellationRequested, Is.True);
|
|
Assert.That(lifetime.StatusCode, Is.EqualTo(StatusCodes.BadTimeout));
|
|
}
|
|
|
|
[Test]
|
|
public void TryCancel_WhenAlreadyCancelled_ReturnsFalse_LeavesStatusCode()
|
|
{
|
|
using var lifetime = new RequestLifetime();
|
|
|
|
bool firstResult = lifetime.TryCancel(StatusCodes.BadTimeout);
|
|
bool secondResult = lifetime.TryCancel(StatusCodes.BadUnexpectedError);
|
|
|
|
Assert.That(firstResult, Is.True);
|
|
Assert.That(secondResult, Is.False);
|
|
Assert.That(lifetime.StatusCode, Is.EqualTo(StatusCodes.BadTimeout));
|
|
Assert.That(lifetime.CancellationToken.IsCancellationRequested, Is.True);
|
|
}
|
|
|
|
[Test]
|
|
public void TryCancelConcurrentCallersFirstStatusWins()
|
|
{
|
|
// a client Cancel, a Session close and the request timeout race to cancel the same
|
|
// request; exactly one of them may claim it, and its status must be the one reported.
|
|
for (int iteration = 0; iteration < 2000; iteration++)
|
|
{
|
|
using var lifetime = new RequestLifetime();
|
|
using var barrier = new Barrier(2);
|
|
bool timeoutWon = false;
|
|
bool cancelWon = false;
|
|
|
|
var timeout = new Thread(() =>
|
|
{
|
|
barrier.SignalAndWait();
|
|
timeoutWon = lifetime.TryCancel(StatusCodes.BadTimeout);
|
|
});
|
|
var cancel = new Thread(() =>
|
|
{
|
|
barrier.SignalAndWait();
|
|
cancelWon = lifetime.TryCancel(StatusCodes.BadRequestCancelledByClient);
|
|
});
|
|
timeout.Start();
|
|
cancel.Start();
|
|
timeout.Join();
|
|
cancel.Join();
|
|
|
|
Assert.That(timeoutWon ^ cancelWon, Is.True, $"Iteration {iteration}: exactly one caller must win.");
|
|
Assert.That(
|
|
lifetime.StatusCode,
|
|
Is.EqualTo(timeoutWon ? StatusCodes.BadTimeout : StatusCodes.BadRequestCancelledByClient),
|
|
$"Iteration {iteration}: the winner's status must be reported.");
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public void TryCancel_AfterDispose_ReturnsFalse()
|
|
{
|
|
var lifetime = new RequestLifetime();
|
|
lifetime.Dispose();
|
|
|
|
bool result = lifetime.TryCancel(StatusCodes.BadTimeout);
|
|
|
|
Assert.That(result, Is.False);
|
|
Assert.That(lifetime.CancellationToken.IsCancellationRequested, Is.False);
|
|
}
|
|
|
|
[Test]
|
|
public void MarkCompleted_DisposesToken_CannotCancel()
|
|
{
|
|
var lifetime = new RequestLifetime();
|
|
lifetime.MarkCompleted();
|
|
|
|
bool result = lifetime.TryCancel(StatusCodes.BadTimeout);
|
|
|
|
Assert.That(result, Is.False);
|
|
|
|
Assert.That(lifetime.CancellationToken.IsCancellationRequested, Is.False);
|
|
}
|
|
|
|
[Test]
|
|
public void None_Property_ReturnsCompletedInstance()
|
|
{
|
|
RequestLifetime lifetime = RequestLifetime.None;
|
|
|
|
Assert.That(lifetime.StatusCode, Is.EqualTo(StatusCodes.Good));
|
|
|
|
bool result = lifetime.TryCancel(StatusCodes.BadTimeout);
|
|
|
|
Assert.That(result, Is.False);
|
|
|
|
Assert.That(lifetime.CancellationToken.IsCancellationRequested, Is.False);
|
|
}
|
|
|
|
[TestCase(false)]
|
|
[TestCase(true)]
|
|
[NonParallelizable]
|
|
public async Task ConcurrentCancellationHasOneTerminalOwnerAsync(bool completeInsteadOfCancel)
|
|
{
|
|
await AssertTerminalRaceAsync(
|
|
completeInsteadOfCancel ? CompetingTransition.Complete : CompetingTransition.Cancel)
|
|
.ConfigureAwait(false);
|
|
}
|
|
|
|
[Test]
|
|
[NonParallelizable]
|
|
public async Task ExternalCancellationHasOneTerminalOwnerAsync()
|
|
{
|
|
await AssertTerminalRaceAsync(CompetingTransition.ExternalCancel).ConfigureAwait(false);
|
|
}
|
|
|
|
[Test]
|
|
public void CompletedLifetimeReleasesLinksWithoutOwningExternalSources()
|
|
{
|
|
using var first = new CancellationTokenSource();
|
|
using var second = new CancellationTokenSource();
|
|
using var lifetime = new RequestLifetime(first.Token, second.Token);
|
|
lifetime.MarkCompleted();
|
|
|
|
first.Cancel();
|
|
second.Cancel();
|
|
|
|
Assert.That(first.IsCancellationRequested, Is.True);
|
|
Assert.That(second.IsCancellationRequested, Is.True);
|
|
Assert.That(lifetime.CancellationToken.IsCancellationRequested, Is.False);
|
|
Assert.That(lifetime.StatusCode, Is.EqualTo(StatusCodes.Good));
|
|
}
|
|
|
|
[Test]
|
|
public void AlreadyCancelledExternalTokenKeepsGoodTerminalStatus()
|
|
{
|
|
using var external = new CancellationTokenSource();
|
|
external.Cancel();
|
|
using var lifetime = new RequestLifetime(external.Token);
|
|
|
|
Assert.That(lifetime.CancellationToken.IsCancellationRequested, Is.True);
|
|
Assert.That(lifetime.StatusCode, Is.EqualTo(StatusCodes.Good));
|
|
Assert.That(lifetime.TryCancel(StatusCodes.BadTimeout), Is.False);
|
|
}
|
|
|
|
[Test]
|
|
public async Task ExternalCancellationCanCompleteInsideItsCallbackAsync()
|
|
{
|
|
using var external = new CancellationTokenSource();
|
|
using var lifetime = new RequestLifetime(external.Token);
|
|
int calls = 0;
|
|
using CancellationTokenRegistration registration = lifetime.CancellationToken.Register(() =>
|
|
{
|
|
Interlocked.Increment(ref calls);
|
|
lifetime.MarkCompleted();
|
|
});
|
|
|
|
await Task.Run(external.Cancel).WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
|
|
|
|
Assert.That(calls, Is.EqualTo(1));
|
|
Assert.That(lifetime.StatusCode, Is.EqualTo(StatusCodes.Good));
|
|
Assert.That(lifetime.TryCancel(StatusCodes.BadTimeout), Is.False);
|
|
}
|
|
|
|
[TestCase(false)]
|
|
[TestCase(true)]
|
|
public void CancellationCallbackCanCompleteWithoutReplacingItsWinningStatus(bool dispose)
|
|
{
|
|
using var lifetime = new RequestLifetime();
|
|
bool nestedCancellation = true;
|
|
StatusCode observed = StatusCodes.Good;
|
|
using CancellationTokenRegistration registration = lifetime.CancellationToken.Register(() =>
|
|
{
|
|
observed = lifetime.StatusCode;
|
|
nestedCancellation = lifetime.TryCancel(StatusCodes.BadUnexpectedError);
|
|
if (dispose)
|
|
{
|
|
lifetime.Dispose();
|
|
}
|
|
else
|
|
{
|
|
lifetime.MarkCompleted();
|
|
}
|
|
});
|
|
|
|
Assert.That(lifetime.TryCancel(StatusCodes.BadTimeout), Is.True);
|
|
Assert.That(nestedCancellation, Is.False);
|
|
Assert.That(observed, Is.EqualTo(StatusCodes.BadTimeout));
|
|
Assert.That(lifetime.StatusCode, Is.EqualTo(StatusCodes.BadTimeout));
|
|
Assert.That(lifetime.TryCancel(StatusCodes.BadUnexpectedError), Is.False);
|
|
}
|
|
|
|
private static async Task AssertTerminalRaceAsync(CompetingTransition competingTransition)
|
|
{
|
|
const int attempts = 16_384;
|
|
var lifetimes = new RequestLifetime[attempts];
|
|
var externalSources = new CancellationTokenSource[attempts];
|
|
var firstWon = new bool[attempts];
|
|
var secondWon = new bool[attempts];
|
|
var observedStatus = new StatusCode[attempts];
|
|
var callbackCounts = new int[attempts];
|
|
using var barrier = new Barrier(2);
|
|
using var timeout = new CancellationTokenSource(TimeSpan.FromMinutes(2));
|
|
for (int i = 0; i < attempts; i++)
|
|
{
|
|
int index = i;
|
|
if (competingTransition == CompetingTransition.ExternalCancel)
|
|
{
|
|
externalSources[i] = new CancellationTokenSource();
|
|
lifetimes[i] = new RequestLifetime(externalSources[i].Token);
|
|
}
|
|
else
|
|
{
|
|
lifetimes[i] = new RequestLifetime();
|
|
}
|
|
lifetimes[i].CancellationToken.Register(() =>
|
|
{
|
|
observedStatus[index] = lifetimes[index].StatusCode;
|
|
Interlocked.Increment(ref callbackCounts[index]);
|
|
});
|
|
}
|
|
|
|
try
|
|
{
|
|
Task first = Task.Factory.StartNew(() =>
|
|
{
|
|
for (int i = 0; i < attempts; i++)
|
|
{
|
|
barrier.SignalAndWait(timeout.Token);
|
|
firstWon[i] = lifetimes[i].TryCancel(StatusCodes.BadTimeout);
|
|
barrier.SignalAndWait(timeout.Token);
|
|
}
|
|
}, CancellationToken.None, TaskCreationOptions.LongRunning, TaskScheduler.Default);
|
|
Task second = Task.Factory.StartNew(() =>
|
|
{
|
|
for (int i = 0; i < attempts; i++)
|
|
{
|
|
barrier.SignalAndWait(timeout.Token);
|
|
if (competingTransition == CompetingTransition.Complete)
|
|
{
|
|
lifetimes[i].MarkCompleted();
|
|
}
|
|
else if (competingTransition == CompetingTransition.ExternalCancel)
|
|
{
|
|
externalSources[i].Cancel();
|
|
}
|
|
else
|
|
{
|
|
secondWon[i] = lifetimes[i].TryCancel(StatusCodes.BadRequestCancelledByRequest);
|
|
}
|
|
barrier.SignalAndWait(timeout.Token);
|
|
}
|
|
}, CancellationToken.None, TaskCreationOptions.LongRunning, TaskScheduler.Default);
|
|
Task workers = Task.WhenAll(first, second);
|
|
Task finished = await Task.WhenAny(
|
|
workers,
|
|
Task.Delay(Timeout.InfiniteTimeSpan, timeout.Token)).ConfigureAwait(false);
|
|
Assert.That(finished, Is.SameAs(workers), "A race worker did not finish before the watchdog.");
|
|
await workers.ConfigureAwait(false);
|
|
|
|
int duplicateOwners = 0;
|
|
int statusMismatches = 0;
|
|
int callbackMismatches = 0;
|
|
for (int i = 0; i < attempts; i++)
|
|
{
|
|
int owners = (firstWon[i] ? 1 : 0) + (secondWon[i] ? 1 : 0);
|
|
if (owners > 1 || (competingTransition == CompetingTransition.Cancel && owners != 1))
|
|
{
|
|
duplicateOwners++;
|
|
}
|
|
StatusCode expected = firstWon[i]
|
|
? StatusCodes.BadTimeout
|
|
: secondWon[i] ? StatusCodes.BadRequestCancelledByRequest : StatusCodes.Good;
|
|
int expectedCallbacks = competingTransition == CompetingTransition.ExternalCancel ? 1 : owners;
|
|
if (lifetimes[i].StatusCode != expected ||
|
|
lifetimes[i].CancellationToken.IsCancellationRequested != (expectedCallbacks == 1))
|
|
{
|
|
statusMismatches++;
|
|
}
|
|
if (callbackCounts[i] != expectedCallbacks ||
|
|
(expectedCallbacks == 1 && observedStatus[i] != expected))
|
|
{
|
|
callbackMismatches++;
|
|
}
|
|
}
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(duplicateOwners, Is.Zero, "Each request has one winning terminal transition.");
|
|
Assert.That(statusMismatches, Is.Zero, "Completion must not race a cancellation status overwrite.");
|
|
Assert.That(callbackMismatches, Is.Zero, "The single callback observes the winning status.");
|
|
});
|
|
}
|
|
finally
|
|
{
|
|
foreach (RequestLifetime lifetime in lifetimes)
|
|
{
|
|
lifetime.Dispose();
|
|
}
|
|
if (competingTransition == CompetingTransition.ExternalCancel)
|
|
{
|
|
foreach (CancellationTokenSource source in externalSources)
|
|
{
|
|
source.Dispose();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private enum CompetingTransition
|
|
{
|
|
Cancel,
|
|
Complete,
|
|
ExternalCancel
|
|
}
|
|
|
|
[TestCase(0)]
|
|
[TestCase(1)]
|
|
public void TimeoutConstructorIsCancelledByEitherToken(int cancelled)
|
|
{
|
|
using var first = new CancellationTokenSource();
|
|
using var second = new CancellationTokenSource();
|
|
using var lifetime = new RequestLifetime(TimeSpan.Zero, first.Token, second.Token);
|
|
|
|
Assert.That(lifetime.CancellationToken.IsCancellationRequested, Is.False);
|
|
(cancelled == 0 ? first : second).Cancel();
|
|
|
|
// the tokens are watched by the shared request sweep, not registered.
|
|
Assert.That(lifetime.CancellationToken.WaitHandle.WaitOne(TimeSpan.FromSeconds(10)), Is.True);
|
|
Assert.That(lifetime.StatusCode, Is.EqualTo(StatusCodes.Good));
|
|
}
|
|
|
|
[Test]
|
|
public void TimeoutConstructorIsCancelledWhenTheTimeoutElapses()
|
|
{
|
|
using var lifetime = new RequestLifetime(
|
|
TimeSpan.FromMilliseconds(20), CancellationToken.None, CancellationToken.None);
|
|
|
|
Assert.That(
|
|
lifetime.CancellationToken.WaitHandle.WaitOne(TimeSpan.FromSeconds(10)), Is.True);
|
|
Assert.That(lifetime.StatusCode, Is.EqualTo(StatusCodes.Good));
|
|
}
|
|
|
|
[Test]
|
|
public void TimeoutConstructorClaimsTheTerminalStateWhenTheTimeoutElapses()
|
|
{
|
|
int callbacks = 0;
|
|
using var lifetime = new RequestLifetime(
|
|
TimeSpan.FromMilliseconds(20), CancellationToken.None, CancellationToken.None);
|
|
using var fired = new ManualResetEventSlim();
|
|
lifetime.CancellationToken.Register(() =>
|
|
{
|
|
Interlocked.Increment(ref callbacks);
|
|
lifetime.MarkCompleted();
|
|
fired.Set();
|
|
});
|
|
|
|
Assert.That(fired.Wait(TimeSpan.FromSeconds(10)), Is.True);
|
|
Assert.That(lifetime.TryCancel(StatusCodes.BadRequestCancelledByClient), Is.False);
|
|
Assert.That(lifetime.StatusCode, Is.EqualTo(StatusCodes.Good));
|
|
Assert.That(callbacks, Is.EqualTo(1));
|
|
}
|
|
|
|
[Test]
|
|
public void TimeoutConstructorLosesToEarlierExplicitCancellation()
|
|
{
|
|
using var lifetime = new RequestLifetime(
|
|
TimeSpan.FromMilliseconds(200), CancellationToken.None, CancellationToken.None);
|
|
|
|
Assert.That(lifetime.TryCancel(StatusCodes.BadRequestCancelledByClient), Is.True);
|
|
Assert.That(lifetime.StatusCode, Is.EqualTo(StatusCodes.BadRequestCancelledByClient));
|
|
Thread.Sleep(400);
|
|
Assert.That(lifetime.StatusCode, Is.EqualTo(StatusCodes.BadRequestCancelledByClient));
|
|
}
|
|
|
|
[Test]
|
|
public void TimeoutConstructorWithoutTimeoutOrTokensIsOnlyCancelledExplicitly()
|
|
{
|
|
using var first = new CancellationTokenSource();
|
|
using var lifetime = new RequestLifetime(Timeout.InfiniteTimeSpan, first.Token, first.Token);
|
|
using var unlinked = new RequestLifetime(TimeSpan.Zero, default, default);
|
|
|
|
Assert.That(lifetime.CancellationToken.WaitHandle.WaitOne(50), Is.False);
|
|
first.Cancel();
|
|
Assert.That(lifetime.CancellationToken.WaitHandle.WaitOne(TimeSpan.FromSeconds(10)), Is.True);
|
|
|
|
Assert.That(unlinked.CancellationToken.CanBeCanceled, Is.True);
|
|
Assert.That(unlinked.TryCancel(StatusCodes.BadRequestCancelledByClient), Is.True);
|
|
Assert.That(unlinked.StatusCode, Is.EqualTo(StatusCodes.BadRequestCancelledByClient));
|
|
}
|
|
|
|
[Test]
|
|
public void TimeoutConstructorRejectsNegativeTimeouts()
|
|
{
|
|
Assert.That(
|
|
() => new RequestLifetime(TimeSpan.FromMilliseconds(-2), default, default),
|
|
Throws.TypeOf<ArgumentOutOfRangeException>());
|
|
}
|
|
|
|
#if NET
|
|
/// <summary>
|
|
/// One token source replaces the token array, the timeout source and its registration
|
|
/// that the params constructor needs for the same three inputs.
|
|
/// </summary>
|
|
[Test]
|
|
public void TimeoutConstructorAllocatesLessThanLinkingATimeoutSource()
|
|
{
|
|
using var request = new CancellationTokenSource();
|
|
using var transport = new CancellationTokenSource();
|
|
var timeout = TimeSpan.FromMinutes(5);
|
|
for (int ii = 0; ii < 2; ii++)
|
|
{
|
|
using var warm = new RequestLifetime(timeout, request.Token, transport.Token);
|
|
using var warmTimeout = new CancellationTokenSource(timeout);
|
|
using var warmOld = new RequestLifetime(request.Token, transport.Token, warmTimeout.Token);
|
|
}
|
|
|
|
long before = GC.GetAllocatedBytesForCurrentThread();
|
|
using (var timeoutSource = new CancellationTokenSource(timeout))
|
|
using (new RequestLifetime(request.Token, transport.Token, timeoutSource.Token))
|
|
{
|
|
}
|
|
long linked = GC.GetAllocatedBytesForCurrentThread() - before;
|
|
|
|
before = GC.GetAllocatedBytesForCurrentThread();
|
|
using (new RequestLifetime(timeout, request.Token, transport.Token))
|
|
{
|
|
}
|
|
long single = GC.GetAllocatedBytesForCurrentThread() - before;
|
|
|
|
TestContext.Out.WriteLine($"params + timeout source: {linked} B, timeout constructor: {single} B");
|
|
Assert.That(single, Is.LessThan(linked));
|
|
}
|
|
#endif
|
|
}
|
|
}
|