mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-16 21:02:51 +08:00
Channel send path: seal responses in the writer, lock-free gate, fewer locks and allocations per request (#4782)
Carved out of #4771 (roadmap item A1 of the o6 benchmark gap analysis): everything except running requests to completion on the receive loop. #4771 keeps run to completion, stacked on this PR, for a later release. Requests still go through the fair request queue and its workers, as on master. What changes is the channel around them: - the response is encoded without the channel gate; - the channel's single writer numbers, secures and sends; - requests take fewer locks and allocations on the channel. No wire, configuration or public API change. ## Results Paired A/B against master `6cfb8cf19` (with #4767). The builds alternate per round on the same machine, and the table shows medians of 5 rounds with the throughput range of the rounds in brackets. The machine was shared with other work, so the CPU and throughput ranges are wide; allocated bytes per request are deterministic. - **o6read** uses the native open62541 client workers of the o6-automation benchmark, in the o6 2.0.1 configuration: `AsyncCustomNodeManager`, Server GC, queue = clients × outstanding + 3. - **"4 cores"** pins the server to 4 cores. - **The other rows** use the harness's .NET client: 20 sessions unless noted, workstation GC, ReferenceServer. - **Contention** counts runtime lock-contention events in the measured window. | Scenario | req/s | Server CPU µs/req | Alloc B/req | Contention | |---|---|---|---|---| | o6read 10×100, 24 cores | 254,600 → 264,700 (+4 %) [228–292k → 255–270k] | 67.4 → 69.5 | 5,874 → 4,709 (**−20 %**) | 6,766 → 1,811 | | o6read 10×1000, 24 cores | 248,900 → 241,300 (−3 %) [231–258k → 233–260k] | 69.0 → 71.3 | 5,866 → 4,718 (**−20 %**) | 6,080 → 1,456 | | o6read 10×100 encrypted, 24 cores | 203,900 → 232,000 (**+14 %**) [111–226k → 204–264k] | 71.6 → 70.2 | 5,985 → 4,752 (**−21 %**) | 5,166 → 1,288 | | o6read 10×100 `--o6-async 0` (`CustomNodeManager2`), 24 cores | 235,600 → 243,900 (+4 %) | 40.1 → 38.8 | 6,702 → 5,886 (−12 %) | 148k → 124k | | o6read 1 client × 1000 (one channel) | 64,300 → 92,600 (**+44 %**) [52–69k → 76–100k] | 79.8 → 74.5 (−7 %) | 6,003 → 5,063 (−16 %) | 224 → 128 | | o6read 10×100, 4 cores | 156,600 → 140,400 (−10 %, noise: rounds 139–192k → 121–184k) | 25.0 → 28.3 | 5,435 → 4,688 (−14 %) | 97 → 184 | | o6read 10×1000, 4 cores | 107,100 → 125,200 (**+17 %**) [103–116k → 120–136k] | 36.5 → 31.7 (**−13 %**) | 9,966 → 4,767 (**−52 %**) | 312 → 160 | | o6read 10×100 encrypted, 4 cores | 103,300 → 126,300 (**+22 %**) [102–105k → 115–143k] | 37.9 → 31.3 (**−17 %**) | 5,498 → 4,736 (−14 %) | 46 → 29 | | o6read 10×100 `--o6-async 0`, 4 cores | 186,600 → 187,700 (+1 %) | 21.1 → 21.0 | 6,587 → 5,915 (−10 %) | 17.7k → 4.2k | | read --nodes 1, 20 sessions | 208,300 → 205,900 (−1 %) | 49.1 → 48.3 | 4,905 → 4,621 (−6 %) | p50/p99 0.072/1.18 → 0.072/1.26 ms | | read --nodes 1, 200 sessions | 216,400 → 208,600 (−4 %) | 42.0 → 43.2 | 4,790 → 4,518 (−6 %) | p50/p99 0.606/3.56 → 0.615/4.05 ms | | read --nodes 100 | 31,950 → 32,470 (+2 %) | 278 → 253 (−9 %) | 73,702 → 73,436 | p50/p99 0.290/2.51 → 0.283/2.48 ms | | browse | 38,380 → 40,460 (+5 %) | 162 → 161 | 39,305 → 38,981 | p50 0.349 → 0.330 ms | | sub (5 × 100 items, 100 ms) | 88,190 → 89,010 notif/s | 11.1 → 10.7 | 159 → 153 | p50/p99 20.7/64.3 → 20.8/66.0 ms | **Where it helps:** - Allocation per request falls in every scenario, by 20 % for o6read. - Lock contention falls by 70–80 % for o6read. - Throughput rises where the channel gate serialised the work: - one deep pipeline on one channel: +44 %; - encrypted: +14 % on 24 cores, +22 % on 4 cores; - 1000 in flight on 4 cores: +17 %. **Where it does not:** CPU per request at 24 cores is unchanged. The requests still go through the fair queue's workers, and the hand-off to a worker and back is what #4771 removes. The 4-core 10×100 row spread as much within each side as between the sides. ## Changes ### Sending - **Seal in the writer.** The producer encodes the response without the channel gate and queues it unsealed. The channel's single writer takes the queued responses in order under a per-channel send-order lock: it assigns sequence numbers, selects the current token, signs and encrypts, and sends the batch. Messages that number themselves (OpenSecureChannel, asymmetric faults, the client's requests) hold the same lock until they are queued. Token changes that can dispose a token take it as well. - **Reconnect.** The old transport is detached before the new token is activated. Unsealed responses still queued for it are then dropped without taking sequence numbers. The new transport is attached and the OpenSecureChannel response numbered under one hold of the send-order lock, so the response is the first message on the new transport. The channel opens again only after that. - **Failure past the sequence numbers.** A message whose security fails after its chunks took sequence numbers stops all numbering on that transport. The writer drops the messages producers numbered for it after the gap, and reports a transport error once it has left the send-order lock. That faults the channel (server) or reconnects (client). - **Numbered writes without the lock.** The writer takes writes that their producers numbered without the send-order lock, because they are queued in number order. A client channel queues only such writes, so its writer no longer waits for the lock that its request threads take. - **Writer on the pool.** The writer always starts on the thread pool. Running it on the producer's thread sent batches of one message. - **Buffers.** The encoded message is a struct in the write-queue entry. A small single-chunk message moves to a right-sized buffer only when writes are already queued, so deep pipelines no longer exhaust the bounded buffer pool (−52 % allocation at 10×1000 on 4 cores). ### Locks and allocations per request - **`ChannelGate`** takes the gate with one compare-and-exchange when it is free, and queues waiters under a lock only when it is not. `SemaphoreSlim` took its monitor on every wait and release, which the receive loop does for every message. The contract is unchanged: not re-entrant, FIFO handoff, cancellable waits, synchronous completion when free. The transport's send lock is a `ChannelGate` too. - **Namespace and server tables.** Every encoder and decoder reads `ServiceMessageContext.NamespaceUris`, `ServerUris` and `MappingVersion`. They are now read lock-free from one immutable snapshot that setters swap atomically. - **Allocation-free tracking.** Tracking a pending request (`PendingRequestScope`) and handing a decoded request out of the gate allocate nothing. - **Single-chunk decode.** A request in one chunk is decoded from that chunk's buffer. - **Fair-queue entry.** An entry coordinates its cancellation registration with an interlocked state (`RegistrationState`) instead of a lock object. ### Client fix `Session.CloseAsync` disposed the session synchronously, and `Dispose` waits for the async dispose of the session and its subscription engine. Closing 1000 sessions at once blocked one pool thread per session while that dispose needed pool threads, and the close took minutes in `ServerManySessionsLoadTestAsync(1000)`. `CloseAsync` now awaits `DisposeAsync`, and dispose awaits an engine that implements `IAsyncDisposable`, as `DefaultSubscriptionEngine` now does. ## Behaviour notes - **Wire order and tokens.** Sequence numbers stay strictly increasing in wire order, and the chunks of one message stay contiguous. Token renewal applies at a message boundary. After a reconnect, the OpenSecureChannel response is the first message on the new transport. - **Back-pressure** (#4707) is unchanged. The receive loop stops reading above 100 queued writes. - **Client.** `Session.CloseAsync` no longer blocks a thread on the dispose. ## Not in this PR The striped server-stop tokens of #4771 are dropped. Since #4767 a server request no longer registers with the stop token, because the deadline monitor polls it. ## Testing - `UaSCBinaryChannelSendOrderTests` covers: - wire order and random interleaving; - token renewal (decrypt and verify) for five policies; - buffers returned once; - a seal failure after numbering stops the channel, and a message numbered after the gap is not sent; - the replaced transport; - back-pressure; - a response that cannot be encoded is answered with a fault. - `TcpReconnectKeyAgreementTests.AResponseQueuedForTheDroppedTransportIsNotSecuredWithTheNewTokenAsync` (new) holds the old transport's sends during a reconnect. It fails on the previous order. The seal-failure test fails without the gap check. - Also run: `ChannelGateTests`, `TcpByteTransportTests`, `ServiceMessageContextMappingTests`. - Local, net10.0: - Opc.Ua.Core.Tests (Stack.Server, Transport, Tcp, ChannelGate): 2739 passed, 0 failed; - Opc.Ua.Configuration.Tests: 241 passed, 0 failed. net48 builds. `dotnet format whitespace --verify-no-changes` passes on the changed files. - CI: the `CI` workflow on this head is running; the commits were tested together with run to completion in #4771 (its last full CI run passed). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -916,7 +916,7 @@ namespace Opc.Ua.Bindings
|
||||
}
|
||||
// Keeps a sessionless channel classified as in use while the
|
||||
// request is processed so admission does not reclaim it.
|
||||
using IDisposable usage = channel.TrackPendingRequest();
|
||||
using TcpListenerChannel.PendingRequestScope usage = channel.TrackPendingRequest();
|
||||
try
|
||||
{
|
||||
var context = new SecureChannelContext(
|
||||
|
||||
@@ -602,7 +602,14 @@ namespace Opc.Ua.Client
|
||||
m_serverCertificate = null;
|
||||
m_keepAliveCancellation?.Dispose();
|
||||
m_keepAliveCancellation = null;
|
||||
m_engine?.Dispose();
|
||||
if (m_engine is IAsyncDisposable asyncEngine)
|
||||
{
|
||||
await asyncEngine.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
m_engine?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
base.Dispose(disposing);
|
||||
@@ -4118,10 +4125,12 @@ namespace Opc.Ua.Client
|
||||
}
|
||||
}
|
||||
|
||||
// clean up.
|
||||
// clean up. Asynchronously: the synchronous Dispose blocks a thread
|
||||
// until the subscription engine has stopped, and many sessions closed
|
||||
// at once that way starve the thread pool their own close needs.
|
||||
if (closeChannel)
|
||||
{
|
||||
Dispose();
|
||||
await DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
@@ -45,7 +45,7 @@ namespace Opc.Ua.Client
|
||||
/// publish pipeline as a drop-in replacement for the
|
||||
/// classic fire-and-forget engine.
|
||||
/// </summary>
|
||||
public sealed class DefaultSubscriptionEngine : ISubscriptionEngine
|
||||
public sealed class DefaultSubscriptionEngine : ISubscriptionEngine, IAsyncDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the
|
||||
@@ -187,6 +187,16 @@ namespace Opc.Ua.Client
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (!m_disposed)
|
||||
{
|
||||
await m_manager.DisposeAsync().ConfigureAwait(false);
|
||||
m_disposed = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void ThrowIfDisposed()
|
||||
{
|
||||
#if NET8_0_OR_GREATER
|
||||
|
||||
@@ -1001,17 +1001,16 @@ namespace Opc.Ua
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
CancellationTokenRegistration registration;
|
||||
lock (m_registrationGate)
|
||||
var prior = (RegistrationState)Interlocked.Exchange(
|
||||
ref m_registrationState, (int)RegistrationState.Disposed);
|
||||
if (prior == RegistrationState.Disposed)
|
||||
{
|
||||
if (m_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
m_disposed = true;
|
||||
registration = m_registration;
|
||||
return;
|
||||
}
|
||||
if (prior == RegistrationState.Registered)
|
||||
{
|
||||
m_registration.Dispose();
|
||||
}
|
||||
registration.Dispose();
|
||||
ReleaseQueueLease();
|
||||
ReleaseExecution();
|
||||
Interlocked.Exchange(ref m_parkedLease, null)?.Dispose();
|
||||
@@ -1040,16 +1039,14 @@ namespace Opc.Ua
|
||||
internal void RegisterCancellation()
|
||||
{
|
||||
CancellationTokenRegistration registration = CancellationToken.Register(() => m_queue.Cancel(this));
|
||||
bool disposed;
|
||||
lock (m_registrationGate)
|
||||
{
|
||||
disposed = m_disposed;
|
||||
if (!disposed)
|
||||
{
|
||||
m_registration = registration;
|
||||
}
|
||||
}
|
||||
if (disposed)
|
||||
m_registration = registration;
|
||||
|
||||
// published unless the entry was disposed meanwhile, possibly by the
|
||||
// callback running inside Register; the registration is ours to end then.
|
||||
if (Interlocked.CompareExchange(
|
||||
ref m_registrationState,
|
||||
(int)RegistrationState.Registered,
|
||||
(int)RegistrationState.None) != (int)RegistrationState.None)
|
||||
{
|
||||
registration.Dispose();
|
||||
}
|
||||
@@ -1060,11 +1057,6 @@ namespace Opc.Ua
|
||||
/// </summary>
|
||||
private readonly FairRequestQueue m_queue;
|
||||
|
||||
/// <summary>
|
||||
/// Serializes registration transfer against entry disposal.
|
||||
/// </summary>
|
||||
private readonly Lock m_registrationGate = new();
|
||||
|
||||
/// <summary>
|
||||
/// The owning FIFO while linked.
|
||||
/// </summary>
|
||||
@@ -1091,14 +1083,37 @@ namespace Opc.Ua
|
||||
private IDisposable? m_parkedLease;
|
||||
|
||||
/// <summary>
|
||||
/// Registration owned until disposal starts.
|
||||
/// Registration owned until disposal starts; published by
|
||||
/// <see cref="m_registrationState"/>.
|
||||
/// </summary>
|
||||
private CancellationTokenRegistration m_registration;
|
||||
|
||||
/// <summary>
|
||||
/// Whether registration ownership has been released.
|
||||
/// A <see cref="RegistrationState"/>, stored as an <see cref="int"/> so that
|
||||
/// <see cref="Interlocked"/> can change it; an entry needs no lock object.
|
||||
/// </summary>
|
||||
private bool m_disposed;
|
||||
private int m_registrationState;
|
||||
|
||||
/// <summary>
|
||||
/// Who owns the cancellation registration of an entry.
|
||||
/// </summary>
|
||||
private enum RegistrationState
|
||||
{
|
||||
/// <summary>
|
||||
/// Not registered yet.
|
||||
/// </summary>
|
||||
None,
|
||||
|
||||
/// <summary>
|
||||
/// Registered; disposal ends the registration.
|
||||
/// </summary>
|
||||
Registered,
|
||||
|
||||
/// <summary>
|
||||
/// Disposal started; a registration made afterwards is ended by its maker.
|
||||
/// </summary>
|
||||
Disposed
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -64,32 +64,26 @@ namespace Opc.Ua.Bindings
|
||||
/// <para>
|
||||
/// It is deliberately <b>not</b> disposable. It replaces a monitor, which has
|
||||
/// no disposed state, and channel teardown enters it and then continues to run
|
||||
/// paths that enter it again. Disposing the underlying semaphore would turn
|
||||
/// those into <see cref="ObjectDisposedException"/> where they previously
|
||||
/// worked. Nothing leaks: the semaphore only allocates an operating system
|
||||
/// wait handle if <see cref="SemaphoreSlim.AvailableWaitHandle"/> is read, and
|
||||
/// it never is.
|
||||
/// paths that enter it again. It holds no operating system resource.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>What it costs.</b> Measured by <c>ChannelGateBenchmarks</c>, an
|
||||
/// uncontended acquisition costs what the underlying
|
||||
/// <see cref="SemaphoreSlim"/> costs and allocates nothing, against about
|
||||
/// 13 ns for the monitor it replaced. The per-message work it is taken
|
||||
/// around — <c>SymmetricChannelCryptoBenchmarks.EncryptSignThenDecryptVerify</c>
|
||||
/// — is about 10,500 ns, so the gate does not register against it. The
|
||||
/// asynchronous entry completes synchronously when uncontended, which a test
|
||||
/// asserts, so it costs no suspension and no task.
|
||||
/// <b>What it costs.</b> An uncontended entry and exit are one interlocked
|
||||
/// compare-and-exchange each and allocate nothing. Only a contended entry
|
||||
/// takes a lock, to queue a waiter, and only an exit with waiters queued takes
|
||||
/// it to hand the gate to the first of them. A <see cref="SemaphoreSlim"/>,
|
||||
/// which this used to wrap, takes its monitor on every wait and release; the
|
||||
/// receive loop takes the gate for every message, and once that monitor had
|
||||
/// seen contention every entry went through its slow path. The asynchronous
|
||||
/// entry completes synchronously when uncontended, which a test asserts, so
|
||||
/// it costs no suspension and no task.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Waiters are served first in, first out, and an exit with waiters hands the
|
||||
/// gate straight to the first one, so a new entrant cannot overtake them. A
|
||||
/// waiter resumes on the thread pool, never on the thread that left the gate.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
// CA1001: the semaphore is deliberately not disposed. This type replaces a
|
||||
// monitor, which has no disposed state, and channel teardown enters the gate
|
||||
// and then runs paths that enter it again; disposing would turn those into
|
||||
// ObjectDisposedException where they previously worked. SemaphoreSlim only
|
||||
// allocates an operating system wait handle when AvailableWaitHandle is read,
|
||||
// which this type never does, so there is nothing to release.
|
||||
#pragma warning disable CA1001
|
||||
internal sealed class ChannelGate
|
||||
#pragma warning restore CA1001
|
||||
{
|
||||
/// <summary>
|
||||
/// Enters the gate, blocking until it is free.
|
||||
@@ -102,7 +96,12 @@ namespace Opc.Ua.Bindings
|
||||
/// </remarks>
|
||||
public Releaser Enter()
|
||||
{
|
||||
m_semaphore.Wait();
|
||||
if (Interlocked.CompareExchange(ref m_state, kHeld, kFree) != kFree)
|
||||
{
|
||||
Waiter? waiter = QueueWaiter();
|
||||
// a queued waiter is completed only by the exit that hands it the gate.
|
||||
waiter?.Task.GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
return new Releaser(this);
|
||||
}
|
||||
@@ -114,18 +113,25 @@ namespace Opc.Ua.Bindings
|
||||
/// <returns>A handle that leaves the gate when disposed.</returns>
|
||||
public ValueTask<Releaser> EnterAsync(CancellationToken ct = default)
|
||||
{
|
||||
Task wait = m_semaphore.WaitAsync(ct);
|
||||
if (ct.IsCancellationRequested)
|
||||
{
|
||||
return new ValueTask<Releaser>(Task.FromCanceled<Releaser>(ct));
|
||||
}
|
||||
|
||||
// Uncontended is the common case. Completing synchronously keeps the
|
||||
// caller's sequencing identical to the lock this replaces.
|
||||
// Task.IsCompletedSuccessfully does not exist on the .NET Framework
|
||||
// targets, so the status is read directly.
|
||||
if (wait.Status == TaskStatus.RanToCompletion)
|
||||
if (Interlocked.CompareExchange(ref m_state, kHeld, kFree) == kFree)
|
||||
{
|
||||
return new ValueTask<Releaser>(new Releaser(this));
|
||||
}
|
||||
|
||||
return AwaitEntryAsync(wait);
|
||||
Waiter? waiter = QueueWaiter();
|
||||
if (waiter == null)
|
||||
{
|
||||
return new ValueTask<Releaser>(new Releaser(this));
|
||||
}
|
||||
|
||||
return AwaitEntryAsync(waiter, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -134,21 +140,234 @@ namespace Opc.Ua.Bindings
|
||||
/// <remarks>
|
||||
/// Used by tests that need to establish contention before entering.
|
||||
/// </remarks>
|
||||
internal bool IsHeldBySomeContextForTest => m_semaphore.CurrentCount == 0;
|
||||
internal bool IsHeldBySomeContextForTest => Volatile.Read(ref m_state) != kFree;
|
||||
|
||||
private async ValueTask<Releaser> AwaitEntryAsync(Task wait)
|
||||
private async ValueTask<Releaser> AwaitEntryAsync(Waiter waiter, CancellationToken ct)
|
||||
{
|
||||
await wait.ConfigureAwait(false);
|
||||
CancellationTokenRegistration registration = default;
|
||||
if (ct.CanBeCanceled)
|
||||
{
|
||||
// runs at once when the token is already cancelled.
|
||||
registration = ct.Register(static state => ((Waiter)state!).Cancel(), waiter);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await waiter.Task.ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
registration.Dispose();
|
||||
}
|
||||
|
||||
return new Releaser(this);
|
||||
}
|
||||
|
||||
private void Leave()
|
||||
/// <summary>
|
||||
/// Queues a waiter for a gate that is held, or takes the gate when it was
|
||||
/// left in the meantime.
|
||||
/// </summary>
|
||||
/// <returns>The waiter, or <see langword="null"/> when the caller took the
|
||||
/// gate.</returns>
|
||||
private Waiter? QueueWaiter()
|
||||
{
|
||||
m_semaphore.Release();
|
||||
lock (m_waitersLock)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
int state = Volatile.Read(ref m_state);
|
||||
if (state == kFree)
|
||||
{
|
||||
if (Interlocked.CompareExchange(ref m_state, kHeld, kFree) == kFree)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
else if (state == kContended ||
|
||||
Interlocked.CompareExchange(ref m_state, kContended, kHeld) == kHeld)
|
||||
{
|
||||
// from here on the exit cannot leave the gate without the lock.
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
var waiter = new Waiter(this);
|
||||
Append(waiter);
|
||||
return waiter;
|
||||
}
|
||||
}
|
||||
|
||||
private readonly SemaphoreSlim m_semaphore = new(1, 1);
|
||||
/// <summary>
|
||||
/// Leaves the gate, or hands it to the first waiter.
|
||||
/// </summary>
|
||||
private void Leave()
|
||||
{
|
||||
int state = Interlocked.CompareExchange(ref m_state, kFree, kHeld);
|
||||
if (state == kHeld)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (state == kFree)
|
||||
{
|
||||
throw new SynchronizationLockException("The channel gate is not held.");
|
||||
}
|
||||
|
||||
Waiter? next;
|
||||
lock (m_waitersLock)
|
||||
{
|
||||
// waiters are queued while the state is contended; a cancelled one
|
||||
// is unlinked, and the last one to go returns the state to held,
|
||||
// possibly after the exchange above saw it contended.
|
||||
next = m_first;
|
||||
if (next == null)
|
||||
{
|
||||
Volatile.Write(ref m_state, kFree);
|
||||
return;
|
||||
}
|
||||
Unlink(next);
|
||||
if (m_first == null)
|
||||
{
|
||||
Volatile.Write(ref m_state, kHeld);
|
||||
}
|
||||
}
|
||||
|
||||
// the gate stays held: ownership passes to the waiter.
|
||||
next.TrySetResult(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a waiter whose wait was cancelled, unless it has already been
|
||||
/// handed the gate.
|
||||
/// </summary>
|
||||
/// <returns><see langword="true"/> when the waiter was removed.</returns>
|
||||
private bool Remove(Waiter waiter)
|
||||
{
|
||||
lock (m_waitersLock)
|
||||
{
|
||||
if (!waiter.Queued)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
Unlink(waiter);
|
||||
if (m_first == null)
|
||||
{
|
||||
// the holder still holds the gate, now without waiters.
|
||||
Volatile.Write(ref m_state, kHeld);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Appends a waiter; called under <see cref="m_waitersLock"/>.
|
||||
/// </summary>
|
||||
private void Append(Waiter waiter)
|
||||
{
|
||||
waiter.Previous = m_last;
|
||||
if (m_last == null)
|
||||
{
|
||||
m_first = waiter;
|
||||
}
|
||||
else
|
||||
{
|
||||
m_last.Next = waiter;
|
||||
}
|
||||
m_last = waiter;
|
||||
waiter.Queued = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unlinks a queued waiter; called under <see cref="m_waitersLock"/>.
|
||||
/// </summary>
|
||||
private void Unlink(Waiter waiter)
|
||||
{
|
||||
if (waiter.Previous == null)
|
||||
{
|
||||
m_first = waiter.Next;
|
||||
}
|
||||
else
|
||||
{
|
||||
waiter.Previous.Next = waiter.Next;
|
||||
}
|
||||
|
||||
if (waiter.Next == null)
|
||||
{
|
||||
m_last = waiter.Previous;
|
||||
}
|
||||
else
|
||||
{
|
||||
waiter.Next.Previous = waiter.Previous;
|
||||
}
|
||||
|
||||
waiter.Next = null;
|
||||
waiter.Previous = null;
|
||||
waiter.Queued = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The gate is free.
|
||||
/// </summary>
|
||||
private const int kFree = 0;
|
||||
|
||||
/// <summary>
|
||||
/// The gate is held and no waiter is queued; it is left without the lock.
|
||||
/// </summary>
|
||||
private const int kHeld = 1;
|
||||
|
||||
/// <summary>
|
||||
/// The gate is held and waiters are queued; leaving it takes the lock.
|
||||
/// </summary>
|
||||
private const int kContended = 2;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="kFree"/>, <see cref="kHeld"/> or <see cref="kContended"/>.
|
||||
/// The change to and from <see cref="kContended"/> is made under
|
||||
/// <see cref="m_waitersLock"/>, the others with a compare-and-exchange.
|
||||
/// </summary>
|
||||
private int m_state;
|
||||
|
||||
/// <summary>
|
||||
/// Protects the waiter queue.
|
||||
/// </summary>
|
||||
private readonly Lock m_waitersLock = new();
|
||||
|
||||
private Waiter? m_first;
|
||||
private Waiter? m_last;
|
||||
|
||||
/// <summary>
|
||||
/// A queued entry, completed with the gate or cancelled.
|
||||
/// </summary>
|
||||
private sealed class Waiter : TaskCompletionSource<bool>
|
||||
{
|
||||
public Waiter(ChannelGate gate)
|
||||
: base(TaskCreationOptions.RunContinuationsAsynchronously)
|
||||
{
|
||||
m_gate = gate;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether the waiter is in the queue; changed under the gate's lock.
|
||||
/// </summary>
|
||||
public bool Queued;
|
||||
|
||||
public Waiter? Next;
|
||||
public Waiter? Previous;
|
||||
|
||||
/// <summary>
|
||||
/// Cancels the wait, unless the gate was handed over already.
|
||||
/// </summary>
|
||||
public void Cancel()
|
||||
{
|
||||
if (m_gate.Remove(this))
|
||||
{
|
||||
TrySetCanceled();
|
||||
}
|
||||
}
|
||||
|
||||
private readonly ChannelGate m_gate;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Leaves the gate when disposed.
|
||||
|
||||
@@ -98,7 +98,6 @@ namespace Opc.Ua.Bindings
|
||||
m_bufferManager = bufferManager ?? throw new ArgumentNullException(nameof(bufferManager));
|
||||
m_receiveBufferSize = receiveBufferSize;
|
||||
m_logger = telemetry.CreateLogger<TcpByteTransport>();
|
||||
m_sendLock = new SemaphoreSlim(1, 1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -232,15 +231,10 @@ namespace Opc.Ua.Bindings
|
||||
using CancellationTokenSource? linkedCts = ct.CanBeCanceled
|
||||
? CancellationTokenSource.CreateLinkedTokenSource(ct, m_sendCancellation.Token)
|
||||
: null;
|
||||
await m_sendLock.WaitAsync(linkedCts?.Token ?? m_sendCancellation.Token).ConfigureAwait(false);
|
||||
try
|
||||
using (await m_sendLock.EnterAsync(linkedCts?.Token ?? m_sendCancellation.Token).ConfigureAwait(false))
|
||||
{
|
||||
await SendMemoryAsync(socket, chunk, ct).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
m_sendLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -257,8 +251,7 @@ namespace Opc.Ua.Bindings
|
||||
using CancellationTokenSource? linkedCts = ct.CanBeCanceled
|
||||
? CancellationTokenSource.CreateLinkedTokenSource(ct, m_sendCancellation.Token)
|
||||
: null;
|
||||
await m_sendLock.WaitAsync(linkedCts?.Token ?? m_sendCancellation.Token).ConfigureAwait(false);
|
||||
try
|
||||
using (await m_sendLock.EnterAsync(linkedCts?.Token ?? m_sendCancellation.Token).ConfigureAwait(false))
|
||||
{
|
||||
if (buffers.Count == 1)
|
||||
{
|
||||
@@ -281,10 +274,6 @@ namespace Opc.Ua.Bindings
|
||||
static (s, pending) => s.SendAsync(pending, SocketFlags.None))
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
m_sendLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
#if NET6_0_OR_GREATER
|
||||
@@ -744,11 +733,11 @@ namespace Opc.Ua.Bindings
|
||||
private bool m_receiveBroken;
|
||||
private readonly ILogger m_logger;
|
||||
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessage(
|
||||
"Usage",
|
||||
"CA2213:Disposable fields should be disposed",
|
||||
Justification = "The semaphore must remain undisposed so queued send waiters can observe transport cancellation and unwind.")]
|
||||
private readonly SemaphoreSlim m_sendLock;
|
||||
/// <summary>
|
||||
/// Serialises the sends. The channel has one writer, so it is uncontended
|
||||
/// there and costs one interlocked exchange each way.
|
||||
/// </summary>
|
||||
private readonly ChannelGate m_sendLock = new();
|
||||
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessage(
|
||||
"Usage",
|
||||
|
||||
@@ -357,10 +357,10 @@ namespace Opc.Ua.Bindings
|
||||
/// <summary>
|
||||
/// Keeps a decoded sessionless request classified as in-use through dispatch and asynchronous processing.
|
||||
/// </summary>
|
||||
internal IDisposable TrackPendingRequest()
|
||||
internal PendingRequestScope TrackPendingRequest()
|
||||
{
|
||||
Interlocked.Increment(ref m_pendingServiceRequests);
|
||||
return new PendingRequest(() => Interlocked.Decrement(ref m_pendingServiceRequests));
|
||||
return new PendingRequestScope(this);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -1042,11 +1042,17 @@ namespace Opc.Ua.Bindings
|
||||
/// <summary>
|
||||
/// Sends a fault response secured with the symmetric keys.
|
||||
/// </summary>
|
||||
/// <param name="token">The token that secures the fault.</param>
|
||||
/// <param name="token">The token of the failed request. The fault is
|
||||
/// secured with the token that is current when the writer sends it, which
|
||||
/// is this one or the one the client activated after it.</param>
|
||||
/// <param name="requestId">The request id of the failed request.</param>
|
||||
/// <param name="fault">The fault to report.</param>
|
||||
/// <param name="requestHandle">The RequestHandle of the failed request, echoed in
|
||||
/// the ResponseHeader as OPC 10000-4 §7.33 recommends; 0 if it is unknown.</param>
|
||||
/// <remarks>
|
||||
/// Needs no lock: the fault is encoded here and queued for the writer, which
|
||||
/// numbers and secures it (see <see cref="UaSCUaBinaryChannel.SendOrder"/>).
|
||||
/// </remarks>
|
||||
protected void SendServiceFault(
|
||||
ChannelToken token,
|
||||
uint requestId,
|
||||
@@ -1055,7 +1061,8 @@ namespace Opc.Ua.Bindings
|
||||
{
|
||||
m_logger.TcpListenChannelLog7(ChannelId, requestId, fault.StatusCode);
|
||||
|
||||
BufferCollection? buffers = null;
|
||||
_ = token;
|
||||
EncodedSymmetricMessage? encoded = null;
|
||||
|
||||
try
|
||||
{
|
||||
@@ -1079,22 +1086,25 @@ namespace Opc.Ua.Bindings
|
||||
|
||||
// the limits should never be exceeded when sending a fault.
|
||||
|
||||
// secure message.
|
||||
buffers = WriteSymmetricMessage(
|
||||
EncodedSymmetricMessage message = EncodeSymmetricMessage(
|
||||
TcpMessageType.Message,
|
||||
requestId,
|
||||
token,
|
||||
response,
|
||||
false,
|
||||
out bool limitsExceeded);
|
||||
out _);
|
||||
encoded = message;
|
||||
|
||||
// send message.
|
||||
BeginWriteMessage(buffers, null);
|
||||
buffers = null;
|
||||
// queue for the writer, which secures it. Without a transport the
|
||||
// fault is dropped, as a write to a closed transport would be.
|
||||
if (BeginWriteSymmetricMessage(message, null))
|
||||
{
|
||||
encoded = null;
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
buffers?.Release(BufferManager, "SendServiceFault");
|
||||
encoded?.Chunks.Release(BufferManager, "SendServiceFault");
|
||||
encoded = null;
|
||||
|
||||
m_logger.TcpListenChannelLog8(e, ChannelId, requestId, fault.StatusCode);
|
||||
|
||||
@@ -1104,6 +1114,10 @@ namespace Opc.Ua.Bindings
|
||||
StatusCodes.BadTcpInternalError,
|
||||
"Unexpected error sending a service fault."));
|
||||
}
|
||||
finally
|
||||
{
|
||||
encoded?.Chunks.Release(BufferManager, "SendServiceFault");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -1242,23 +1256,32 @@ namespace Opc.Ua.Bindings
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Keeps a decoded request in use until its owner releases the pending-request count once.
|
||||
/// Keeps a decoded request in use until its owner releases the pending-request count.
|
||||
/// </summary>
|
||||
/// <param name="release">Callback that returns this request's pending count.</param>
|
||||
private sealed class PendingRequest(Action release) : IDisposable
|
||||
/// <remarks>
|
||||
/// A struct, so tracking a request allocates nothing. Every use is a
|
||||
/// <see langword="using"/> declaration over the value returned by
|
||||
/// <see cref="TrackPendingRequest"/>, which disposes it exactly once.
|
||||
/// </remarks>
|
||||
internal readonly struct PendingRequestScope : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the pending-request count at most once.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
internal PendingRequestScope(TcpListenerChannel channel)
|
||||
{
|
||||
Interlocked.Exchange(ref m_release, null)?.Invoke();
|
||||
m_channel = channel;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pending-count release callback, cleared atomically when consumed.
|
||||
/// Returns the pending-request count.
|
||||
/// </summary>
|
||||
private Action? m_release = release;
|
||||
public void Dispose()
|
||||
{
|
||||
if (m_channel != null)
|
||||
{
|
||||
Interlocked.Decrement(ref m_channel.m_pendingServiceRequests);
|
||||
}
|
||||
}
|
||||
|
||||
private readonly TcpListenerChannel? m_channel;
|
||||
}
|
||||
|
||||
private readonly ILogger m_logger;
|
||||
|
||||
@@ -366,32 +366,41 @@ namespace Opc.Ua.Bindings
|
||||
throw new ObjectDisposedException(nameof(TcpServerChannel));
|
||||
}
|
||||
|
||||
// put channel back in open state.
|
||||
// Retire the old loop BEFORE closing the socket it reads
|
||||
// from. Closing first makes that loop fail out of
|
||||
// ReceiveChunkAsync with a socket error rather than a
|
||||
// cancellation, and its error path faults the channel - on
|
||||
// the new transport, after this method has already reported
|
||||
// the reconnect as successful. Detached before the new token
|
||||
// is activated, nothing still queued for the old transport is
|
||||
// secured with that token: the writer drops it unnumbered, and
|
||||
// responses produced meanwhile wait for the gate.
|
||||
dropped = DetachTransport();
|
||||
|
||||
ActivateToken(token);
|
||||
State = TcpChannelState.Open;
|
||||
|
||||
if (transport is IUaSCByteTransportLimits transportLimits)
|
||||
{
|
||||
transportLimits.SetReceiveBufferSize(ReceiveBufferSize);
|
||||
}
|
||||
|
||||
// Retire the old loop BEFORE closing the socket it reads
|
||||
// from. Closing first makes that loop fail out of
|
||||
// ReceiveChunkAsync with a socket error rather than a
|
||||
// cancellation, and its error path faults the channel - on
|
||||
// the new transport, after this method has already reported
|
||||
// the reconnect as successful.
|
||||
dropped = DetachTransport();
|
||||
|
||||
Transport = transport;
|
||||
if (Volatile.Read(ref m_disposed) != 0)
|
||||
{
|
||||
throw new ObjectDisposedException(nameof(TcpServerChannel));
|
||||
}
|
||||
StartReceiveLoop();
|
||||
|
||||
// send response.
|
||||
SendOpenSecureChannelResponse(requestId, token, request, true);
|
||||
// The transport and the response change together under the
|
||||
// send order lock, so the writer numbers nothing in between:
|
||||
// the response is the first message on the new transport.
|
||||
using (SendOrder.Enter())
|
||||
{
|
||||
Transport = transport;
|
||||
StartReceiveLoop();
|
||||
SendOpenSecureChannelResponse(requestId, token, request, true);
|
||||
}
|
||||
|
||||
// put channel back in open state.
|
||||
State = TcpChannelState.Open;
|
||||
|
||||
// send any queued responses.
|
||||
ResetQueuedResponses(OnChannelReconnected);
|
||||
@@ -427,7 +436,7 @@ namespace Opc.Ua.Bindings
|
||||
ArraySegment<byte> messageChunk,
|
||||
CancellationToken ct)
|
||||
{
|
||||
using IDisposable usage = TrackPendingRequest();
|
||||
using PendingRequestScope usage = TrackPendingRequest();
|
||||
PendingRequestDispatch? pending = null;
|
||||
bool ownsBuffer;
|
||||
|
||||
@@ -503,11 +512,11 @@ namespace Opc.Ua.Bindings
|
||||
// Outside the gate on purpose: the handler is application code, and
|
||||
// its continuation may call SendResponse straight back on this
|
||||
// stack. See ProcessRequestMessage.
|
||||
if (pending != null)
|
||||
if (pending is PendingRequestDispatch dispatch)
|
||||
{
|
||||
try
|
||||
{
|
||||
RequestReceived?.Invoke(this, pending.RequestId, pending.Request);
|
||||
RequestReceived?.Invoke(this, dispatch.RequestId, dispatch.Request);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
@@ -519,7 +528,7 @@ namespace Opc.Ua.Bindings
|
||||
}
|
||||
finally
|
||||
{
|
||||
pending.Chunks?.Release(BufferManager, "ProcessRequestMessage");
|
||||
dispatch.Chunks?.Release(BufferManager, "ProcessRequestMessage");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1259,19 +1268,24 @@ namespace Opc.Ua.Bindings
|
||||
// key served over a network still occupies a thread; that is recorded
|
||||
// as a residual limitation rather than cascading async through eight
|
||||
// fault call sites.
|
||||
chunksToSend = WriteAsymmetricMessage(
|
||||
TcpMessageType.Open,
|
||||
requestId,
|
||||
ServerCertificate,
|
||||
senderCertificateChain: null,
|
||||
ClientCertificate,
|
||||
new ArraySegment<byte>(buffer, 0, buffer.Length),
|
||||
!renew ? m_oscRequestSignature : null,
|
||||
out _);
|
||||
// Numbered and queued under SendOrder, ahead of the responses the
|
||||
// writer has not numbered yet.
|
||||
using (SendOrder.Enter())
|
||||
{
|
||||
chunksToSend = WriteAsymmetricMessage(
|
||||
TcpMessageType.Open,
|
||||
requestId,
|
||||
ServerCertificate,
|
||||
senderCertificateChain: null,
|
||||
ClientCertificate,
|
||||
new ArraySegment<byte>(buffer, 0, buffer.Length),
|
||||
!renew ? m_oscRequestSignature : null,
|
||||
out _);
|
||||
|
||||
// write the message to the server.
|
||||
BeginWriteMessage(chunksToSend, null);
|
||||
chunksToSend = null;
|
||||
// write the message to the server.
|
||||
BeginWriteMessage(chunksToSend, null);
|
||||
chunksToSend = null;
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
@@ -1300,8 +1314,9 @@ namespace Opc.Ua.Bindings
|
||||
/// <remarks>
|
||||
/// Used by the synchronous reconnect handoff, which is reached through a
|
||||
/// public override that cannot become asynchronous without breaking the
|
||||
/// contract. Signing here with a key served over a network still occupies
|
||||
/// a thread; the open path proper uses
|
||||
/// contract, and which holds <see cref="UaSCUaBinaryChannel.SendOrder"/>
|
||||
/// while it calls this. Signing here with a key served over a network still
|
||||
/// occupies a thread; the open path proper uses
|
||||
/// <see cref="SendOpenSecureChannelResponseAsync"/> and does not.
|
||||
/// </remarks>
|
||||
private void SendOpenSecureChannelResponse(
|
||||
@@ -1325,6 +1340,8 @@ namespace Opc.Ua.Bindings
|
||||
|
||||
byte[] buffer = BinaryEncoder.EncodeMessage(response, Quotas.MessageContext);
|
||||
|
||||
// numbered and queued under SendOrder, which the caller holds; see
|
||||
// SendOpenSecureChannelResponseAsync.
|
||||
BufferCollection chunksToSend = WriteAsymmetricMessage(
|
||||
TcpMessageType.Open,
|
||||
requestId,
|
||||
@@ -1374,22 +1391,30 @@ namespace Opc.Ua.Bindings
|
||||
|
||||
byte[] buffer = BinaryEncoder.EncodeMessage(response, Quotas.MessageContext);
|
||||
|
||||
AsymmetricWriteResult written = await WriteAsymmetricMessageAsync(
|
||||
TcpMessageType.Open,
|
||||
requestId,
|
||||
ServerCertificate,
|
||||
ServerCertificateChain,
|
||||
ClientCertificate,
|
||||
new ArraySegment<byte>(buffer, 0, buffer.Length),
|
||||
!renew ? m_oscRequestSignature : null,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
if (!renew)
|
||||
// A renewal is answered while responses are in flight. The response takes
|
||||
// its sequence number here, so it is numbered and queued under SendOrder:
|
||||
// the writer sends it ahead of the responses it has not numbered yet. The
|
||||
// writer waits while the response is signed, as responses waited for the
|
||||
// gate before.
|
||||
using (await SendOrder.EnterAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
ChannelThumbprint = written.Signature;
|
||||
}
|
||||
AsymmetricWriteResult written = await WriteAsymmetricMessageAsync(
|
||||
TcpMessageType.Open,
|
||||
requestId,
|
||||
ServerCertificate,
|
||||
ServerCertificateChain,
|
||||
ClientCertificate,
|
||||
new ArraySegment<byte>(buffer, 0, buffer.Length),
|
||||
!renew ? m_oscRequestSignature : null,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
SendOpenSecureChannelChunks(written.Chunks);
|
||||
if (!renew)
|
||||
{
|
||||
ChannelThumbprint = written.Signature;
|
||||
}
|
||||
|
||||
SendOpenSecureChannelChunks(written.Chunks);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -1515,7 +1540,7 @@ namespace Opc.Ua.Bindings
|
||||
/// Stream decoding copies the request's strings and byte strings; an asynchronous
|
||||
/// callback may continue after these buffers are released.
|
||||
/// </param>
|
||||
private sealed record PendingRequestDispatch(
|
||||
private readonly record struct PendingRequestDispatch(
|
||||
uint RequestId,
|
||||
IServiceRequest Request,
|
||||
BufferCollection? Chunks);
|
||||
@@ -1676,10 +1701,7 @@ namespace Opc.Ua.Bindings
|
||||
}
|
||||
|
||||
// decode the request.
|
||||
using var serviceRequestStream = new ArraySegmentStream(chunksToProcess);
|
||||
IServiceRequest request = BinaryDecoder.DecodeMessage<IServiceRequest>(
|
||||
serviceRequestStream,
|
||||
Quotas.MessageContext);
|
||||
IServiceRequest request = DecodeRequest(chunksToProcess);
|
||||
if (request == null)
|
||||
{
|
||||
SendServiceFault(
|
||||
@@ -1742,6 +1764,26 @@ namespace Opc.Ua.Bindings
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decodes the request in the chunks of a message.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A request in one chunk, which most are, is decoded from that chunk's
|
||||
/// buffer without a stream over the chunk list. Either decoder copies the
|
||||
/// values it reads, so the request does not borrow the pooled buffers.
|
||||
/// </remarks>
|
||||
private IServiceRequest DecodeRequest(BufferCollection chunks)
|
||||
{
|
||||
if (chunks.Count == 1)
|
||||
{
|
||||
using var decoder = new BinaryDecoder(chunks[0], Quotas.MessageContext);
|
||||
return decoder.DecodeMessage<IServiceRequest>();
|
||||
}
|
||||
|
||||
using var stream = new ArraySegmentStream(chunks);
|
||||
return BinaryDecoder.DecodeMessage<IServiceRequest>(stream, Quotas.MessageContext);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the RequestHandle of a request message that could not be
|
||||
/// processed, for the ServiceFault that reports it.
|
||||
@@ -1772,6 +1814,11 @@ namespace Opc.Ua.Bindings
|
||||
throw new ArgumentNullException(nameof(response));
|
||||
}
|
||||
|
||||
if (TrySendResponseWithoutGate(requestId, response))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
using (Gate.Enter())
|
||||
{
|
||||
return SendResponseCore(requestId, response);
|
||||
@@ -1783,11 +1830,11 @@ namespace Opc.Ua.Bindings
|
||||
/// without blocking the calling thread.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Used by the request dispatch path, which runs on thread-pool workers. With many
|
||||
/// requests in flight on one channel, the synchronous <see cref="SendResponse"/>
|
||||
/// parked one pool thread per waiting response on the gate; the receive loop that
|
||||
/// was handed the gate then waited for a free pool thread to continue, and the
|
||||
/// channel stalled until the pool injected more threads.
|
||||
/// Used by the request dispatch path, which runs on thread-pool workers. An open
|
||||
/// channel takes no lock for a response: it is encoded on the caller's thread and
|
||||
/// queued for the writer, which numbers and secures it (see
|
||||
/// <see cref="UaSCUaBinaryChannel.SendOrder"/>). Only a channel that is not open, or
|
||||
/// whose renewed token is due for activation, waits for the gate.
|
||||
/// </remarks>
|
||||
/// <returns><c>true</c> if the response was retained for delivery after reconnect.</returns>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="response"/> is <c>null</c>.</exception>
|
||||
@@ -1799,12 +1846,92 @@ namespace Opc.Ua.Bindings
|
||||
throw new ArgumentNullException(nameof(response));
|
||||
}
|
||||
|
||||
if (TrySendResponseWithoutGate(requestId, response))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
using (await Gate.EnterAsync().ConfigureAwait(false))
|
||||
{
|
||||
return SendResponseCore(requestId, response);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encodes and queues a response without the channel gate, when the channel
|
||||
/// is open and no token change is due.
|
||||
/// </summary>
|
||||
/// <param name="requestId">The request being answered.</param>
|
||||
/// <param name="response">The response.</param>
|
||||
/// <returns><see langword="false"/> when the caller must send the response
|
||||
/// under the gate instead: the channel is not open, a renewed token is due
|
||||
/// for activation, the transport is gone, or the response cannot be encoded
|
||||
/// (the gate path sends the fault). Nothing was sent then.</returns>
|
||||
/// <remarks>
|
||||
/// The state is read without the gate, so the channel can fault or close
|
||||
/// right after the check. The response then goes to the transport the
|
||||
/// channel had, and is lost with it, like a response whose write was queued
|
||||
/// just before the channel faulted always was.
|
||||
/// </remarks>
|
||||
private bool TrySendResponseWithoutGate(
|
||||
uint requestId,
|
||||
IServiceResponse response)
|
||||
{
|
||||
if (State != TcpChannelState.Open ||
|
||||
IsRenewedTokenActivationDue ||
|
||||
Transport == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
EncodedSymmetricMessage encoded;
|
||||
try
|
||||
{
|
||||
// note that the server does nothing if the message limits are exceeded.
|
||||
encoded = EncodeSymmetricMessage(
|
||||
TcpMessageType.Message,
|
||||
requestId,
|
||||
response,
|
||||
false,
|
||||
out _);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// the gate path encodes again and reports the failure.
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!BeginWriteSymmetricMessage(encoded, null))
|
||||
{
|
||||
encoded.Chunks.Release(BufferManager, "SendResponse");
|
||||
return false;
|
||||
}
|
||||
|
||||
m_eventLogger.CoreSendResponse((int)ChannelId, (int)requestId);
|
||||
TrackSessions(response);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Counts the sessions activated and closed on the channel, when no session
|
||||
/// binding provider does.
|
||||
/// </summary>
|
||||
private void TrackSessions(IServiceResponse response)
|
||||
{
|
||||
if (Quotas.SessionBindingProvider == null &&
|
||||
response is ActivateSessionResponse activateSessionResponse &&
|
||||
StatusCode.IsGood(activateSessionResponse.ResponseHeader.ServiceResult))
|
||||
{
|
||||
AddSession();
|
||||
}
|
||||
else if (Quotas.SessionBindingProvider == null &&
|
||||
response is CloseSessionResponse closeSessionResponse &&
|
||||
StatusCode.IsGood(closeSessionResponse.ResponseHeader.ServiceResult))
|
||||
{
|
||||
RemoveSession();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encodes and queues a response while the caller holds the channel gate.
|
||||
/// </summary>
|
||||
@@ -1827,23 +1954,21 @@ namespace Opc.Ua.Bindings
|
||||
}
|
||||
|
||||
m_eventLogger.CoreSendResponse((int)ChannelId, (int)requestId);
|
||||
BufferCollection? buffers = null;
|
||||
|
||||
// a response sent long after its request (Publish) must not be secured
|
||||
// with a token that has expired while the client sent nothing.
|
||||
ActivateRenewedTokenIfDue();
|
||||
|
||||
EncodedSymmetricMessage encoded;
|
||||
try
|
||||
{
|
||||
// note that the server does nothing if the message limits are exceeded.
|
||||
|
||||
buffers = WriteSymmetricMessage(
|
||||
encoded = EncodeSymmetricMessage(
|
||||
TcpMessageType.Message,
|
||||
requestId,
|
||||
CurrentToken!,
|
||||
response,
|
||||
false,
|
||||
out bool limitsExceeded);
|
||||
out _);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
@@ -1859,32 +1984,16 @@ namespace Opc.Ua.Bindings
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
// queued for the writer, which secures it with the token current then.
|
||||
if (!BeginWriteSymmetricMessage(encoded, null))
|
||||
{
|
||||
BeginWriteMessage(buffers, null);
|
||||
buffers = null!;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
buffers?.Release(BufferManager, "SendResponse");
|
||||
encoded.Chunks.Release(BufferManager, "SendResponse");
|
||||
|
||||
m_queuedResponses[requestId] = response;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (Quotas.SessionBindingProvider == null &&
|
||||
response is ActivateSessionResponse activateSessionResponse &&
|
||||
StatusCode.IsGood(activateSessionResponse.ResponseHeader.ServiceResult))
|
||||
{
|
||||
AddSession();
|
||||
}
|
||||
else if (Quotas.SessionBindingProvider == null &&
|
||||
response is CloseSessionResponse closeSessionResponse &&
|
||||
StatusCode.IsGood(closeSessionResponse.ResponseHeader.ServiceResult))
|
||||
{
|
||||
RemoveSession();
|
||||
}
|
||||
|
||||
TrackSessions(response);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1710,7 +1710,7 @@ namespace Opc.Ua.Bindings
|
||||
uint requestId,
|
||||
IServiceRequest request)
|
||||
{
|
||||
using IDisposable usage = channel.TrackPendingRequest();
|
||||
using TcpListenerChannel.PendingRequestScope usage = channel.TrackPendingRequest();
|
||||
IServiceResponse? response = null;
|
||||
bool responseRetained = false;
|
||||
try
|
||||
|
||||
@@ -32,6 +32,7 @@ using System.Buffers.Binary;
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Opc.Ua.Bindings
|
||||
@@ -200,18 +201,27 @@ namespace Opc.Ua.Bindings
|
||||
}
|
||||
m_renewedTokenWithKeys = null;
|
||||
|
||||
// a pending renewal superseded by another token (reconnect) is dropped.
|
||||
if (RenewedToken != null && !ReferenceEquals(RenewedToken, token))
|
||||
ChannelToken? previousToken;
|
||||
|
||||
// the writer secures messages with the current token under the send
|
||||
// order lock; swapping it there keeps a token from being disposed while
|
||||
// a chunk is secured with it.
|
||||
using (SendOrder.Enter())
|
||||
{
|
||||
RenewedToken.Dispose();
|
||||
// a pending renewal superseded by another token (reconnect) is dropped.
|
||||
if (RenewedToken != null && !ReferenceEquals(RenewedToken, token))
|
||||
{
|
||||
RenewedToken.Dispose();
|
||||
}
|
||||
|
||||
PreviousToken?.Dispose();
|
||||
PreviousToken = CurrentToken;
|
||||
CurrentToken = token;
|
||||
RenewedToken = null;
|
||||
previousToken = PreviousToken;
|
||||
}
|
||||
|
||||
PreviousToken?.Dispose();
|
||||
PreviousToken = CurrentToken;
|
||||
CurrentToken = token;
|
||||
RenewedToken = null;
|
||||
|
||||
TokenActivatedCallback?.Invoke(token, PreviousToken);
|
||||
TokenActivatedCallback?.Invoke(token, previousToken);
|
||||
|
||||
if (m_logger.IsEnabled(LogLevel.Information))
|
||||
{
|
||||
@@ -278,17 +288,34 @@ namespace Opc.Ua.Bindings
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether <see cref="ActivateRenewedTokenIfDue"/> would activate a token.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Read without the gate by a sender deciding whether it may skip it. The
|
||||
/// answer can be stale by the time the message is sealed, which only moves
|
||||
/// the activation to the next message or the next received chunk.
|
||||
/// </remarks>
|
||||
private protected bool IsRenewedTokenActivationDue =>
|
||||
RenewedToken != null &&
|
||||
CurrentToken is ChannelToken currentToken &&
|
||||
currentToken.IsActivationRequired(TimeProvider);
|
||||
|
||||
/// <summary>
|
||||
/// Discards the tokens.
|
||||
/// </summary>
|
||||
protected void DiscardTokens()
|
||||
{
|
||||
PreviousToken?.Dispose();
|
||||
PreviousToken = null;
|
||||
CurrentToken?.Dispose();
|
||||
CurrentToken = null;
|
||||
RenewedToken?.Dispose();
|
||||
RenewedToken = null;
|
||||
// see ActivateToken.
|
||||
using (SendOrder.Enter())
|
||||
{
|
||||
PreviousToken?.Dispose();
|
||||
PreviousToken = null;
|
||||
CurrentToken?.Dispose();
|
||||
CurrentToken = null;
|
||||
RenewedToken?.Dispose();
|
||||
RenewedToken = null;
|
||||
}
|
||||
|
||||
TokenActivatedCallback?.Invoke(null, null);
|
||||
}
|
||||
@@ -536,6 +563,13 @@ namespace Opc.Ua.Bindings
|
||||
/// <summary>
|
||||
/// Secures the message using the security token.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Encodes and seals in one step. The sequence numbers are taken under
|
||||
/// <see cref="SendOrder"/>, but the caller queues the chunks after it is
|
||||
/// released, so this is only correct on a channel that queues no unsealed
|
||||
/// messages (<see cref="BeginWriteSymmetricMessage"/>) and serialises its
|
||||
/// numbered writes otherwise, as the client channel does under its gate.
|
||||
/// </remarks>
|
||||
protected BufferCollection WriteSymmetricMessage(
|
||||
uint messageType,
|
||||
uint requestId,
|
||||
@@ -543,6 +577,89 @@ namespace Opc.Ua.Bindings
|
||||
object messageBody,
|
||||
bool isRequest,
|
||||
out bool limitsExceeded)
|
||||
{
|
||||
EncodedSymmetricMessage encoded = EncodeSymmetricMessage(
|
||||
messageType,
|
||||
requestId,
|
||||
messageBody,
|
||||
isRequest,
|
||||
out limitsExceeded);
|
||||
|
||||
using (SendOrder.Enter())
|
||||
{
|
||||
return SealSymmetricMessage(encoded, token);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A symmetric message encoded into chunks whose headers, sequence numbers
|
||||
/// and security are not written yet.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Encoding needs nothing that is ordered per channel, so any thread can do
|
||||
/// it without the gate. Sealing takes the token and the sequence numbers and
|
||||
/// is done by the writer, in queue order (see <see cref="SendOrder"/>).
|
||||
/// </remarks>
|
||||
private protected readonly struct EncodedSymmetricMessage
|
||||
{
|
||||
public EncodedSymmetricMessage(
|
||||
uint messageType,
|
||||
uint requestId,
|
||||
bool isRequest,
|
||||
bool aborted,
|
||||
BufferCollection chunks)
|
||||
{
|
||||
MessageType = messageType;
|
||||
RequestId = requestId;
|
||||
IsRequest = isRequest;
|
||||
Aborted = aborted;
|
||||
Chunks = chunks;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The message type without the chunk type.
|
||||
/// </summary>
|
||||
public uint MessageType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The request the message belongs to.
|
||||
/// </summary>
|
||||
public uint RequestId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the message is a request (client keys secure it).
|
||||
/// </summary>
|
||||
public bool IsRequest { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the last chunk is an abort chunk replacing the rest of the
|
||||
/// message because it exceeded the limits.
|
||||
/// </summary>
|
||||
public bool Aborted { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The chunks, each holding its body after the reserved header space.
|
||||
/// Owned by the message until sealed.
|
||||
/// </summary>
|
||||
public BufferCollection Chunks { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encodes a message into chunks without securing them.
|
||||
/// </summary>
|
||||
/// <param name="messageType">The message type without the chunk type.</param>
|
||||
/// <param name="requestId">The request the message belongs to.</param>
|
||||
/// <param name="messageBody">An encodeable, or the raw bytes to send.</param>
|
||||
/// <param name="isRequest">Whether the message is a request.</param>
|
||||
/// <param name="limitsExceeded">Set when the message was cut off with an
|
||||
/// abort chunk because it exceeds the negotiated limits.</param>
|
||||
/// <returns>The encoded message. The caller owns its chunks.</returns>
|
||||
private protected EncodedSymmetricMessage EncodeSymmetricMessage(
|
||||
uint messageType,
|
||||
uint requestId,
|
||||
object messageBody,
|
||||
bool isRequest,
|
||||
out bool limitsExceeded)
|
||||
{
|
||||
limitsExceeded = false;
|
||||
bool success = false;
|
||||
@@ -559,11 +676,7 @@ namespace Opc.Ua.Bindings
|
||||
int maxPlainTextSize = maxCipherBlocks * EncryptionBlockSize;
|
||||
|
||||
int signatureSize = SymmetricSignatureSize;
|
||||
// token.SecurityPolicy is set in ComputeKeys before the channel sends messages.
|
||||
int paddingCountSize =
|
||||
SecurityMode != MessageSecurityMode.SignAndEncrypt || token.SecurityPolicy!.NoSymmetricEncryptionPadding
|
||||
? 0
|
||||
: (EncryptionBlockSize > byte.MaxValue ? 2 : 1);
|
||||
int paddingCountSize = GetPaddingCountSize();
|
||||
|
||||
int maxPayloadSize =
|
||||
maxPlainTextSize -
|
||||
@@ -618,9 +731,8 @@ namespace Opc.Ua.Bindings
|
||||
chunksToProcess.Add(new ArraySegment<byte>(buffer, headerSize, 0));
|
||||
}
|
||||
|
||||
var chunksToSend = new BufferCollection(chunksToProcess.Capacity);
|
||||
|
||||
int messageSize = 0;
|
||||
int keptCount = chunksToProcess.Count;
|
||||
|
||||
for (int ii = 0; ii < chunksToProcess.Count; ii++)
|
||||
{
|
||||
@@ -635,10 +747,6 @@ namespace Opc.Ua.Bindings
|
||||
continue;
|
||||
}
|
||||
|
||||
// The headers are fixed-size little-endian fields in front of the
|
||||
// body, written directly rather than through an encoder per chunk.
|
||||
uint chunkType;
|
||||
|
||||
// check if the message needs to be aborted. The segment holds
|
||||
// only the body (it starts after the headers), which is what
|
||||
// the receiver counts against MaxMessageSize.
|
||||
@@ -647,8 +755,6 @@ namespace Opc.Ua.Bindings
|
||||
messageSize + chunkToProcess.Count,
|
||||
ii + 1))
|
||||
{
|
||||
chunkType = messageType | TcpMessageType.Abort;
|
||||
|
||||
// Replace the body in the chunk with an error message.
|
||||
// The encoder is bounded by the room the chunk has for a
|
||||
// body, not by what the chunk currently holds: an error
|
||||
@@ -667,24 +773,118 @@ namespace Opc.Ua.Bindings
|
||||
? StatusCodes.BadRequestTooLarge
|
||||
: StatusCodes.BadResponseTooLarge);
|
||||
int size = errorEncoder.Close();
|
||||
chunkToProcess = new ArraySegment<byte>(
|
||||
chunksToProcess[ii] = new ArraySegment<byte>(
|
||||
chunkArray,
|
||||
chunkToProcess.Offset,
|
||||
size);
|
||||
}
|
||||
|
||||
limitsExceeded = true;
|
||||
keptCount = ii + 1;
|
||||
}
|
||||
|
||||
// check if the message is complete.
|
||||
else if (ii == chunksToProcess.Count - 1)
|
||||
// update message size count.
|
||||
messageSize += chunksToProcess[ii].Count;
|
||||
}
|
||||
|
||||
// the chunks after an abort chunk were returned above.
|
||||
if (keptCount < chunksToProcess.Count)
|
||||
{
|
||||
chunksToProcess.RemoveRange(keptCount, chunksToProcess.Count - keptCount);
|
||||
}
|
||||
|
||||
// A small message that will wait in the write queue (writes are already
|
||||
// queued) moves to a buffer of its own size: a deep pipeline of
|
||||
// responses that each hold a send-buffer-sized array exhausts the
|
||||
// buffer pool. A message that is sent right away skips the copy. The
|
||||
// room after the body covers the padding count, the padding and the
|
||||
// signature that sealing adds.
|
||||
if (chunksToProcess.Count == 1 && Volatile.Read(ref m_activeWriteRequests) > 0)
|
||||
{
|
||||
ArraySegment<byte> body = chunksToProcess[0];
|
||||
int required = headerSize + body.Count + paddingCountSize +
|
||||
signatureSize + (2 * EncryptionBlockSize);
|
||||
if (required <= sendBufferSize / kSmallMessageFraction)
|
||||
{
|
||||
chunkType = messageType | TcpMessageType.Final;
|
||||
byte[] large = body.GetArray();
|
||||
byte[] small = BufferManager.TakeBuffer(required, "WriteSymmetricMessage");
|
||||
Buffer.BlockCopy(large, body.Offset, small, headerSize, body.Count);
|
||||
chunksToProcess[0] = new ArraySegment<byte>(small, headerSize, body.Count);
|
||||
BufferManager.ReturnBuffer(large, "WriteSymmetricMessage");
|
||||
}
|
||||
}
|
||||
|
||||
var encoded = new EncodedSymmetricMessage(
|
||||
messageType,
|
||||
requestId,
|
||||
isRequest,
|
||||
limitsExceeded,
|
||||
chunksToProcess);
|
||||
|
||||
// ensure the buffers don't get cleaned up on exit.
|
||||
success = true;
|
||||
return encoded;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (!success)
|
||||
{
|
||||
chunksToProcess?.Release(BufferManager, "WriteSymmetricMessage");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the headers and sequence numbers of an encoded message and
|
||||
/// secures its chunks with the token.
|
||||
/// </summary>
|
||||
/// <param name="encoded">The message. Its chunks are consumed: returned
|
||||
/// as the result, or released when sealing fails.</param>
|
||||
/// <param name="token">The token that secures the message.</param>
|
||||
/// <returns>The chunks to send.</returns>
|
||||
/// <remarks>
|
||||
/// The caller holds <see cref="SendOrder"/>, which is what keeps the
|
||||
/// sequence numbers in the order the chunks reach the wire and keeps the
|
||||
/// token from being disposed while it is in use.
|
||||
/// </remarks>
|
||||
private protected BufferCollection SealSymmetricMessage(
|
||||
EncodedSymmetricMessage encoded,
|
||||
ChannelToken token)
|
||||
{
|
||||
BufferCollection chunksToProcess = encoded.Chunks;
|
||||
bool success = false;
|
||||
|
||||
try
|
||||
{
|
||||
int signatureSize = SymmetricSignatureSize;
|
||||
int paddingCountSize = GetPaddingCountSize();
|
||||
|
||||
const int headerSize = TcpMessageLimits.SymmetricHeaderSize +
|
||||
TcpMessageLimits.SequenceHeaderSize;
|
||||
|
||||
var chunksToSend = new BufferCollection(chunksToProcess.Count);
|
||||
int lastChunk = chunksToProcess.Count - 1;
|
||||
|
||||
for (int ii = 0; ii < chunksToProcess.Count; ii++)
|
||||
{
|
||||
ArraySegment<byte> chunkToProcess = chunksToProcess[ii];
|
||||
// BufferManager-backed segment always has a non-null backing array.
|
||||
byte[] chunkArray = chunkToProcess.GetArray();
|
||||
|
||||
// The headers are fixed-size little-endian fields in front of the
|
||||
// body, written directly rather than through an encoder per chunk.
|
||||
uint chunkType;
|
||||
if (ii < lastChunk)
|
||||
{
|
||||
chunkType = encoded.MessageType | TcpMessageType.Intermediate;
|
||||
}
|
||||
else if (encoded.Aborted)
|
||||
{
|
||||
chunkType = encoded.MessageType | TcpMessageType.Abort;
|
||||
}
|
||||
// more chunks to follow.
|
||||
else
|
||||
{
|
||||
chunkType = messageType | TcpMessageType.Intermediate;
|
||||
chunkType = encoded.MessageType | TcpMessageType.Final;
|
||||
}
|
||||
|
||||
int count = 0;
|
||||
@@ -721,14 +921,11 @@ namespace Opc.Ua.Bindings
|
||||
|
||||
uint sequenceNumber = GetNewSequenceNumber();
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(header[16..], sequenceNumber);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(header[20..], requestId);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(header[20..], encoded.RequestId);
|
||||
|
||||
// the headers are followed by the body.
|
||||
int position = headerSize + chunkToProcess.Count;
|
||||
|
||||
// update message size count.
|
||||
messageSize += chunkToProcess.Count;
|
||||
|
||||
ArraySegment<byte> dataToSend;
|
||||
|
||||
if (SecurityMode != MessageSecurityMode.None)
|
||||
@@ -738,7 +935,13 @@ namespace Opc.Ua.Bindings
|
||||
TcpMessageLimits.SymmetricHeaderSize,
|
||||
position - TcpMessageLimits.SymmetricHeaderSize);
|
||||
|
||||
dataToSend = EncryptAndSign(token, dataToSend, isRequest);
|
||||
// the number was taken above under the same lock, so the
|
||||
// last one sent is the one before this chunk's.
|
||||
dataToSend = EncryptAndSign(
|
||||
token,
|
||||
dataToSend,
|
||||
encoded.IsRequest,
|
||||
LastSentSequenceNumber);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -760,15 +963,27 @@ namespace Opc.Ua.Bindings
|
||||
{
|
||||
if (!success)
|
||||
{
|
||||
chunksToProcess?.Release(BufferManager, "WriteSymmetricMessage");
|
||||
chunksToProcess.Release(BufferManager, "WriteSymmetricMessage");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The size of the padding count field of an encrypted chunk.
|
||||
/// </summary>
|
||||
private int GetPaddingCountSize()
|
||||
{
|
||||
return SecurityMode != MessageSecurityMode.SignAndEncrypt ||
|
||||
SecurityPolicy!.NoSymmetricEncryptionPadding
|
||||
? 0
|
||||
: (EncryptionBlockSize > byte.MaxValue ? 2 : 1);
|
||||
}
|
||||
|
||||
private ArraySegment<byte> EncryptAndSign(
|
||||
ChannelToken token,
|
||||
ArraySegment<byte> dataToEncrypt,
|
||||
bool useClientKeys)
|
||||
bool useClientKeys,
|
||||
uint lastSequenceNumber)
|
||||
{
|
||||
// The keys and security policy are populated in ComputeKeys before send/receive.
|
||||
return CryptoUtils.SymmetricEncryptAndSign(
|
||||
@@ -780,7 +995,7 @@ namespace Opc.Ua.Bindings
|
||||
useClientKeys ? token.ClientHmac : token.ServerHmac,
|
||||
SecurityMode == MessageSecurityMode.Sign,
|
||||
token.TokenId,
|
||||
LastSentSequenceNumber, // already incremented to create this message. need the last one sent.
|
||||
lastSequenceNumber,
|
||||
m_symmetricProvider,
|
||||
useClientKeys ? token.ClientCiphers : token.ServerCiphers);
|
||||
}
|
||||
@@ -978,6 +1193,12 @@ namespace Opc.Ua.Bindings
|
||||
/// was activated.
|
||||
/// </summary>
|
||||
private ChannelToken? m_renewedTokenWithKeys;
|
||||
|
||||
/// <summary>
|
||||
/// A single-chunk message that needs at most this fraction of the send buffer
|
||||
/// is moved to a buffer of its own size (see <see cref="EncodeSymmetricMessage"/>).
|
||||
/// </summary>
|
||||
private const int kSmallMessageFraction = 8;
|
||||
private int m_signatureKeySize;
|
||||
private int m_encryptionKeySize;
|
||||
private ISymmetricCryptoProvider? m_symmetricProvider;
|
||||
|
||||
@@ -1449,9 +1449,10 @@ namespace Opc.Ua.Bindings
|
||||
/// Both matter. Running off the caller's stack is required because the
|
||||
/// write reports completion through <see cref="HandleWriteComplete"/>,
|
||||
/// which enters the gate the caller may be holding. Preserving order is
|
||||
/// required because sequence numbers are assigned under that gate, and a
|
||||
/// peer rejects a chunk whose sequence number does not follow its
|
||||
/// predecessor. Independently queued work items carry no such guarantee,
|
||||
/// required because sequence numbers are assigned in queue order (see
|
||||
/// <see cref="SendOrder"/>), and a peer rejects a chunk whose sequence
|
||||
/// number does not follow its predecessor. Independently queued work items
|
||||
/// carry no such guarantee,
|
||||
/// so the writes are queued and a single writer on the thread pool sends
|
||||
/// them one after the other; a write cannot start before the one issued
|
||||
/// before it has finished.
|
||||
@@ -1468,6 +1469,61 @@ namespace Opc.Ua.Bindings
|
||||
m_writerRunning = true;
|
||||
}
|
||||
|
||||
ScheduleWriter();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Queues a message encoded by <see cref="EncodeSymmetricMessage"/> for the
|
||||
/// writer, which seals it in queue order and sends it.
|
||||
/// </summary>
|
||||
/// <param name="message">The message. Ownership transfers when this
|
||||
/// returns <see langword="true"/>.</param>
|
||||
/// <param name="state">Reported to <see cref="HandleWriteComplete"/>.</param>
|
||||
/// <returns><see langword="false"/> when no transport is attached; the
|
||||
/// caller still owns the message.</returns>
|
||||
/// <remarks>
|
||||
/// The caller needs no lock: nothing ordered per channel is decided until
|
||||
/// the writer seals the message. See <see cref="SendOrder"/>.
|
||||
/// <para>
|
||||
/// The writer always starts on the pool, never on the caller's thread. Run
|
||||
/// on the thread that produced the first response, it sealed and sent
|
||||
/// batches of one message, which cost more send calls and serialised the
|
||||
/// signing on the producers; on four cores, encrypted, it lost 30 % of the
|
||||
/// throughput a pool-scheduled writer reaches with larger coalesced batches.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private protected bool BeginWriteSymmetricMessage(
|
||||
EncodedSymmetricMessage message,
|
||||
object? state)
|
||||
{
|
||||
IUaSCByteTransport? transport = m_transport;
|
||||
if (transport == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
Interlocked.Increment(ref m_activeWriteRequests);
|
||||
var write = new PendingWrite(transport, default, null, null, state, message);
|
||||
|
||||
lock (m_writeQueueLock)
|
||||
{
|
||||
m_unsealedWriteQueue.Enqueue(write);
|
||||
if (m_writerRunning)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
m_writerRunning = true;
|
||||
}
|
||||
|
||||
ScheduleWriter();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts the writer on the thread pool.
|
||||
/// </summary>
|
||||
private void ScheduleWriter()
|
||||
{
|
||||
// The execution context is not needed by the write and is not flowed.
|
||||
// The write goes to the local queue of the issuing thread, which picks
|
||||
// it up as soon as it returns to the pool (typically to await the
|
||||
@@ -1488,46 +1544,70 @@ namespace Opc.Ua.Bindings
|
||||
#endif
|
||||
|
||||
/// <summary>
|
||||
/// Sends the queued writes in order until the queue is empty.
|
||||
/// Seals and sends the queued writes in order until the queue is empty.
|
||||
/// </summary>
|
||||
private async Task RunWritesAsync()
|
||||
{
|
||||
List<PendingWrite> batch = m_writeBatch;
|
||||
while (true)
|
||||
{
|
||||
PendingWrite write;
|
||||
bool numbered;
|
||||
lock (m_writeQueueLock)
|
||||
{
|
||||
if (m_writeQueue.Count == 0)
|
||||
if (m_writeQueue.Count == 0 && m_unsealedWriteQueue.Count == 0)
|
||||
{
|
||||
m_writerRunning = false;
|
||||
return;
|
||||
}
|
||||
write = m_writeQueue.Dequeue();
|
||||
|
||||
// Writes queued behind this one for the same transport go out
|
||||
// with it in one vectored send, when the transport is a stream.
|
||||
if (m_writeQueue.Count > 0 &&
|
||||
write.Transport is IUaSCChunkCoalescing coalescing &&
|
||||
coalescing.CanCoalesceChunks)
|
||||
{
|
||||
batch.Add(write);
|
||||
while (m_writeQueue.Count > 0 &&
|
||||
batch.Count < kMaxCoalescedWrites &&
|
||||
ReferenceEquals(m_writeQueue.Peek().Transport, write.Transport))
|
||||
{
|
||||
batch.Add(m_writeQueue.Dequeue());
|
||||
}
|
||||
}
|
||||
// Writes numbered by their producers go out without the send
|
||||
// order lock: each was queued under it, after the writes numbered
|
||||
// before it. A client channel only queues such writes.
|
||||
numbered = m_writeQueue.Count > 0 && TakeWriteBatch(batch, includeUnsealed: false);
|
||||
}
|
||||
|
||||
Task pending;
|
||||
if (batch.Count > 0)
|
||||
if (numbered)
|
||||
{
|
||||
pending = WriteCoalescedAsync(write.Transport, batch);
|
||||
DiscardWritesNumberedAfterGap(batch);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Sealing takes sequence numbers and the current token, so it runs
|
||||
// under the send order lock, and in queue order. The batch is taken
|
||||
// under the same lock: see SendOrder for why that orders the wire.
|
||||
using (await SendOrder.EnterAsync().ConfigureAwait(false))
|
||||
{
|
||||
lock (m_writeQueueLock)
|
||||
{
|
||||
if (!TakeWriteBatch(batch, includeUnsealed: true))
|
||||
{
|
||||
m_writerRunning = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
DiscardWritesNumberedAfterGap(batch);
|
||||
SealWriteBatch(batch);
|
||||
}
|
||||
|
||||
ReportSequenceFailure();
|
||||
}
|
||||
|
||||
if (batch.Count == 0)
|
||||
{
|
||||
// every message in the batch failed to seal and was reported.
|
||||
continue;
|
||||
}
|
||||
|
||||
Task pending;
|
||||
if (batch.Count > 1)
|
||||
{
|
||||
pending = WriteCoalescedAsync(batch[0].Transport, batch);
|
||||
}
|
||||
else
|
||||
{
|
||||
PendingWrite write = batch[0];
|
||||
batch.Clear();
|
||||
pending = write.Buffers != null
|
||||
? WriteBuffersAsync(write.Transport, write.Buffers, write.State)
|
||||
: WriteSingleChunkAsync(write.Transport, write.Chunk, write.BackingBuffer!, write.State);
|
||||
@@ -1555,9 +1635,219 @@ namespace Opc.Ua.Bindings
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Takes the next writes to send together, the numbered ones first.
|
||||
/// </summary>
|
||||
/// <param name="batch">Receives the writes; empty on entry.</param>
|
||||
/// <param name="includeUnsealed">Whether unsealed writes may be taken; only
|
||||
/// a caller that holds <see cref="SendOrder"/> may number them.</param>
|
||||
/// <returns><see langword="false"/> when nothing is queued that may be taken.</returns>
|
||||
/// <remarks>
|
||||
/// Called under the write queue lock, and under <see cref="SendOrder"/> when
|
||||
/// <paramref name="includeUnsealed"/> is set. Every sealed write in the
|
||||
/// queue was numbered before the caller took <see cref="SendOrder"/>, so it
|
||||
/// goes out before the unsealed writes the caller is about to number. Writes
|
||||
/// queued behind the first one for the same transport go out with it in one
|
||||
/// vectored send, when the transport is a stream.
|
||||
/// </remarks>
|
||||
private bool TakeWriteBatch(List<PendingWrite> batch, bool includeUnsealed)
|
||||
{
|
||||
PendingWrite first;
|
||||
if (m_writeQueue.Count > 0)
|
||||
{
|
||||
first = m_writeQueue.Dequeue();
|
||||
}
|
||||
else if (includeUnsealed && m_unsealedWriteQueue.Count > 0)
|
||||
{
|
||||
first = m_unsealedWriteQueue.Dequeue();
|
||||
}
|
||||
else
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
batch.Add(first);
|
||||
|
||||
if (first.Transport is IUaSCChunkCoalescing coalescing &&
|
||||
coalescing.CanCoalesceChunks)
|
||||
{
|
||||
while (m_writeQueue.Count > 0 &&
|
||||
batch.Count < kMaxCoalescedWrites &&
|
||||
ReferenceEquals(m_writeQueue.Peek().Transport, first.Transport))
|
||||
{
|
||||
batch.Add(m_writeQueue.Dequeue());
|
||||
}
|
||||
|
||||
// unsealed writes only once no sealed write is left ahead of them.
|
||||
while (includeUnsealed &&
|
||||
m_writeQueue.Count == 0 &&
|
||||
m_unsealedWriteQueue.Count > 0 &&
|
||||
batch.Count < kMaxCoalescedWrites &&
|
||||
ReferenceEquals(m_unsealedWriteQueue.Peek().Transport, first.Transport))
|
||||
{
|
||||
batch.Add(m_unsealedWriteQueue.Dequeue());
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seals the unsealed writes of a batch in order, and drops and reports
|
||||
/// those that cannot be sealed.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Called under <see cref="SendOrder"/>. A write that fails is reported as
|
||||
/// a failed write, after its chunks have been returned.
|
||||
/// <para>
|
||||
/// A message that fails after it took sequence numbers leaves a gap the
|
||||
/// peer rejects. The writer then numbers nothing more for that transport,
|
||||
/// and <see cref="RunWritesAsync"/> faults the channel once it has left
|
||||
/// the lock.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void SealWriteBatch(List<PendingWrite> batch)
|
||||
{
|
||||
int kept = 0;
|
||||
for (int ii = 0; ii < batch.Count; ii++)
|
||||
{
|
||||
PendingWrite write = batch[ii];
|
||||
if (write.Unsealed is EncodedSymmetricMessage message)
|
||||
{
|
||||
// A message queued for a transport the channel no longer uses (a
|
||||
// reconnect replaced it, or the channel closed) is not numbered:
|
||||
// after a reconnect the peer continues from the OpenSecureChannel
|
||||
// response on the new transport and would see a gap. It is
|
||||
// dropped like a write to the closed transport.
|
||||
// After a message failed once it had taken sequence numbers, nothing
|
||||
// more is numbered for that transport either: the peer would see the gap.
|
||||
ChannelToken? token = CurrentToken;
|
||||
if (token == null ||
|
||||
!ReferenceEquals(write.Transport, m_transport) ||
|
||||
ReferenceEquals(write.Transport, m_sequenceBrokenTransport))
|
||||
{
|
||||
message.Chunks.Release(BufferManager, nameof(SealWriteBatch));
|
||||
ReportWriteComplete(
|
||||
null,
|
||||
write.State,
|
||||
0,
|
||||
ServiceResult.Create(
|
||||
StatusCodes.BadSecureChannelClosed,
|
||||
"The channel was closed before the message was sent."));
|
||||
continue;
|
||||
}
|
||||
|
||||
long numbered = Interlocked.Read(ref m_sequenceNumber);
|
||||
try
|
||||
{
|
||||
// returns the chunks to the pool when it fails.
|
||||
BufferCollection sealedChunks = SealSymmetricMessage(message, token);
|
||||
write = new PendingWrite(write.Transport, default, null, sealedChunks, write.State);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
m_logger.UaSCChannelSealFailed(ex, ChannelId, message.RequestId);
|
||||
var failure = ServiceResult.Create(
|
||||
ex,
|
||||
StatusCodes.BadTcpInternalError,
|
||||
"Could not secure the outgoing message.");
|
||||
if (Interlocked.Read(ref m_sequenceNumber) != numbered)
|
||||
{
|
||||
// it failed after taking sequence numbers (its security failed).
|
||||
m_sequenceBrokenTransport = write.Transport;
|
||||
m_sequenceFailure = failure;
|
||||
}
|
||||
ReportWriteComplete(null, write.State, 0, failure);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
batch[kept++] = write;
|
||||
}
|
||||
|
||||
if (kept < batch.Count)
|
||||
{
|
||||
batch.RemoveRange(kept, batch.Count - kept);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drops the writes of a batch that their producers numbered for a transport
|
||||
/// on which a failed seal already left a gap in the sequence numbers.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A producer can number a message for that transport (an OpenSecureChannel
|
||||
/// response or a fault, or a client request) before the channel faults:
|
||||
/// <see cref="ReportSequenceFailure"/> faults it from the pool. Sent, it
|
||||
/// would reach the peer after the gap. The writer seals only once no write
|
||||
/// numbered by a producer is queued ahead, and producers cannot number while
|
||||
/// it holds <see cref="SendOrder"/>, so every such write queued for the
|
||||
/// broken transport was numbered after the gap. Unsealed writes are left to
|
||||
/// <see cref="SealWriteBatch"/>.
|
||||
/// </remarks>
|
||||
private void DiscardWritesNumberedAfterGap(List<PendingWrite> batch)
|
||||
{
|
||||
IUaSCByteTransport? broken = m_sequenceBrokenTransport;
|
||||
if (broken == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
int kept = 0;
|
||||
for (int ii = 0; ii < batch.Count; ii++)
|
||||
{
|
||||
PendingWrite write = batch[ii];
|
||||
if (write.Unsealed == null && ReferenceEquals(write.Transport, broken))
|
||||
{
|
||||
if (write.Buffers == null && write.BackingBuffer != null)
|
||||
{
|
||||
BufferManager.ReturnBuffer(write.BackingBuffer, nameof(DiscardWritesNumberedAfterGap));
|
||||
}
|
||||
ReportWriteComplete(
|
||||
write.Buffers,
|
||||
write.State,
|
||||
0,
|
||||
ServiceResult.Create(
|
||||
StatusCodes.BadSecureChannelClosed,
|
||||
"The channel was closed before the message was sent."));
|
||||
continue;
|
||||
}
|
||||
|
||||
batch[kept++] = write;
|
||||
}
|
||||
|
||||
if (kept < batch.Count)
|
||||
{
|
||||
batch.RemoveRange(kept, batch.Count - kept);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Faults the channel on the transport whose sequence numbers a failed seal
|
||||
/// left with a gap, once the writer no longer holds <see cref="SendOrder"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The fault takes the gate, which comes before <see cref="SendOrder"/> in
|
||||
/// the lock order, so it runs on the pool, as a failed write is reported.
|
||||
/// It is ignored once a reconnect replaced the transport.
|
||||
/// </remarks>
|
||||
private void ReportSequenceFailure()
|
||||
{
|
||||
ServiceResult? failure = m_sequenceFailure;
|
||||
if (failure == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
m_sequenceFailure = null;
|
||||
IUaSCByteTransport transport = m_sequenceBrokenTransport!;
|
||||
_ = Task.Run(() => OnTransportError(transport, failure, CancellationToken.None));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A write waiting for its turn: either one contiguous chunk with the
|
||||
/// buffer to return, or a collection of buffers.
|
||||
/// buffer to return, a collection of buffers, or a message the writer
|
||||
/// still has to seal.
|
||||
/// </summary>
|
||||
private readonly struct PendingWrite
|
||||
{
|
||||
@@ -1566,13 +1856,15 @@ namespace Opc.Ua.Bindings
|
||||
ReadOnlyMemory<byte> chunk,
|
||||
byte[]? backingBuffer,
|
||||
BufferCollection? buffers,
|
||||
object? state)
|
||||
object? state,
|
||||
EncodedSymmetricMessage? unsealed = null)
|
||||
{
|
||||
Transport = transport;
|
||||
Chunk = chunk;
|
||||
BackingBuffer = backingBuffer;
|
||||
Buffers = buffers;
|
||||
State = state;
|
||||
Unsealed = unsealed;
|
||||
}
|
||||
|
||||
public IUaSCByteTransport Transport { get; }
|
||||
@@ -1580,6 +1872,7 @@ namespace Opc.Ua.Bindings
|
||||
public byte[]? BackingBuffer { get; }
|
||||
public BufferCollection? Buffers { get; }
|
||||
public object? State { get; }
|
||||
public EncodedSymmetricMessage? Unsealed { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -2070,6 +2363,41 @@ namespace Opc.Ua.Bindings
|
||||
/// </remarks>
|
||||
internal ChannelGate Gate { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Orders the sequence numbers of outgoing chunks with the order they
|
||||
/// reach the transport, and guards the token the writer secures them with.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// OPC 10000-6 §6.7.2.4 requires the sequence numbers on the wire to
|
||||
/// increase by one per chunk, and a message's chunks to be contiguous.
|
||||
/// Messages are numbered in one of two ways, both under this lock:
|
||||
/// <list type="bullet">
|
||||
/// <item>The writer seals unsealed messages
|
||||
/// (<see cref="BeginWriteSymmetricMessage"/>) while it holds the lock,
|
||||
/// in queue order, after taking them from the queue under it.</item>
|
||||
/// <item>A producer that numbers its own message (asymmetric messages,
|
||||
/// <see cref="WriteSymmetricMessage"/>) holds the lock from taking the
|
||||
/// numbers until the message is in the queue of sealed writes, unless its
|
||||
/// channel queues no unsealed messages and orders those producers itself
|
||||
/// (the client, under its gate).</item>
|
||||
/// </list>
|
||||
/// The writer sends every sealed write ahead of the unsealed ones it takes
|
||||
/// with them, so a write numbered by a producer — necessarily before the
|
||||
/// writer took the lock — precedes the numbers the writer assigns, and one
|
||||
/// writer sends batches one after the other. Sealed writes alone the writer
|
||||
/// takes without the lock: they are in the queue in the order of their
|
||||
/// numbers, and a write still being numbered under the lock is queued after
|
||||
/// them. Messages without sequence numbers (Hello, Acknowledge,
|
||||
/// ReverseHello, Error) need no lock.
|
||||
/// <para>
|
||||
/// Token changes that can dispose a token take the lock too, so the writer
|
||||
/// never secures a chunk with a disposed token. Lock order: <see cref="Gate"/>,
|
||||
/// then this, then the write queue lock. Nothing takes the gate while
|
||||
/// holding this.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal ChannelGate SendOrder { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// The byte-level transport that carries UASC chunks for the channel.
|
||||
/// Set by listener channels after a successful accept/connect, by
|
||||
@@ -2274,8 +2602,21 @@ namespace Opc.Ua.Bindings
|
||||
private TaskCompletionSource<bool>? m_writeCapacityWaiter;
|
||||
private readonly Lock m_writeQueueLock = new();
|
||||
private readonly Queue<PendingWrite> m_writeQueue = new();
|
||||
private readonly Queue<PendingWrite> m_unsealedWriteQueue = new();
|
||||
private bool m_writerRunning;
|
||||
|
||||
/// <summary>
|
||||
/// The transport on which a message failed after taking sequence numbers;
|
||||
/// nothing more is numbered for it. Changed under <see cref="SendOrder"/>.
|
||||
/// </summary>
|
||||
private IUaSCByteTransport? m_sequenceBrokenTransport;
|
||||
|
||||
/// <summary>
|
||||
/// Why <see cref="m_sequenceBrokenTransport"/> was set, until the writer
|
||||
/// reports it (<see cref="ReportSequenceFailure"/>).
|
||||
/// </summary>
|
||||
private ServiceResult? m_sequenceFailure;
|
||||
|
||||
/// <summary>
|
||||
/// The most writes sent together in one vectored send.
|
||||
/// </summary>
|
||||
@@ -2626,5 +2967,13 @@ namespace Opc.Ua.Bindings
|
||||
uint channelId,
|
||||
long limitBytes,
|
||||
bool hasSession);
|
||||
|
||||
[LoggerMessage(EventId = CoreEventIds.UaSCBinaryChannel + 18, Level = LogLevel.Warning,
|
||||
Message = "ChannelId {ChannelId}: Could not secure the message for RequestId {RequestId}; it is not sent.")]
|
||||
public static partial void UaSCChannelSealFailed(
|
||||
this ILogger logger,
|
||||
Exception exception,
|
||||
uint channelId,
|
||||
uint requestId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,48 +147,49 @@ namespace Opc.Ua
|
||||
public int MaxDecoderRecoveries { get; set; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <remarks>
|
||||
/// Read without a lock: every encoder and decoder of a server reads it, from
|
||||
/// all request threads at once, and a lock around a reference read became a
|
||||
/// waiting point once responses were encoded concurrently. The tables and
|
||||
/// <see cref="MappingVersion"/> are published together, as one immutable
|
||||
/// snapshot, so a reader that sees the version unchanged around reading a
|
||||
/// table read the table of that version. Setters serialise on a lock.
|
||||
/// </remarks>
|
||||
public NamespaceTable NamespaceUris
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (m_mappingGate)
|
||||
{
|
||||
return m_namespaceUris;
|
||||
}
|
||||
}
|
||||
get => Volatile.Read(ref m_mapping).NamespaceUris;
|
||||
set
|
||||
{
|
||||
lock (m_mappingGate)
|
||||
{
|
||||
value ??= new NamespaceTable();
|
||||
if (!ReferenceEquals(m_namespaceUris, value))
|
||||
MappingSnapshot current = m_mapping;
|
||||
if (!ReferenceEquals(current.NamespaceUris, value))
|
||||
{
|
||||
m_namespaceUris = value;
|
||||
m_mappingVersion++;
|
||||
Volatile.Write(
|
||||
ref m_mapping,
|
||||
new MappingSnapshot(value, current.ServerUris, current.Version + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <remarks>Read without a lock; see <see cref="NamespaceUris"/>.</remarks>
|
||||
public StringTable ServerUris
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (m_mappingGate)
|
||||
{
|
||||
return m_serverUris;
|
||||
}
|
||||
}
|
||||
get => Volatile.Read(ref m_mapping).ServerUris;
|
||||
set
|
||||
{
|
||||
lock (m_mappingGate)
|
||||
{
|
||||
value ??= new StringTable();
|
||||
if (!ReferenceEquals(m_serverUris, value))
|
||||
MappingSnapshot current = m_mapping;
|
||||
if (!ReferenceEquals(current.ServerUris, value))
|
||||
{
|
||||
m_serverUris = value;
|
||||
m_mappingVersion++;
|
||||
Volatile.Write(
|
||||
ref m_mapping,
|
||||
new MappingSnapshot(current.NamespaceUris, value, current.Version + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -215,20 +216,28 @@ namespace Opc.Ua
|
||||
/// <summary>
|
||||
/// Gets the monotonic version of the namespace/server table holders.
|
||||
/// </summary>
|
||||
internal long MappingVersion
|
||||
internal long MappingVersion => Volatile.Read(ref m_mapping).Version;
|
||||
|
||||
/// <summary>
|
||||
/// The namespace and server tables with the version they were published at.
|
||||
/// </summary>
|
||||
private sealed class MappingSnapshot
|
||||
{
|
||||
get
|
||||
public MappingSnapshot(NamespaceTable namespaceUris, StringTable serverUris, long version)
|
||||
{
|
||||
lock (m_mappingGate)
|
||||
{
|
||||
return m_mappingVersion;
|
||||
}
|
||||
NamespaceUris = namespaceUris;
|
||||
ServerUris = serverUris;
|
||||
Version = version;
|
||||
}
|
||||
|
||||
public NamespaceTable NamespaceUris { get; }
|
||||
|
||||
public StringTable ServerUris { get; }
|
||||
|
||||
public long Version { get; }
|
||||
}
|
||||
|
||||
private readonly Lock m_mappingGate = new();
|
||||
private NamespaceTable m_namespaceUris = new();
|
||||
private StringTable m_serverUris = new();
|
||||
private long m_mappingVersion;
|
||||
private MappingSnapshot m_mapping = new(new NamespaceTable(), new StringTable(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,10 +294,10 @@ namespace Opc.Ua.Core.Tests.Stack.Transport
|
||||
using Socket _s = serverSocket;
|
||||
using (client)
|
||||
{
|
||||
var sendLock = (SemaphoreSlim)typeof(TcpByteTransport)
|
||||
var sendLock = (ChannelGate)typeof(TcpByteTransport)
|
||||
.GetField("m_sendLock", BindingFlags.Instance | BindingFlags.NonPublic)!
|
||||
.GetValue(client)!;
|
||||
sendLock.Wait();
|
||||
ChannelGate.Releaser held = sendLock.Enter();
|
||||
try
|
||||
{
|
||||
using var cts = new CancellationTokenSource();
|
||||
@@ -314,7 +314,7 @@ namespace Opc.Ua.Core.Tests.Stack.Transport
|
||||
}
|
||||
finally
|
||||
{
|
||||
sendLock.Release();
|
||||
held.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -328,10 +328,10 @@ namespace Opc.Ua.Core.Tests.Stack.Transport
|
||||
using Socket _s = serverSocket;
|
||||
using (client)
|
||||
{
|
||||
var sendLock = (SemaphoreSlim)typeof(TcpByteTransport)
|
||||
var sendLock = (ChannelGate)typeof(TcpByteTransport)
|
||||
.GetField("m_sendLock", BindingFlags.Instance | BindingFlags.NonPublic)!
|
||||
.GetValue(client)!;
|
||||
sendLock.Wait();
|
||||
ChannelGate.Releaser held = sendLock.Enter();
|
||||
try
|
||||
{
|
||||
Task send = client.SendChunkAsync(
|
||||
@@ -349,7 +349,7 @@ namespace Opc.Ua.Core.Tests.Stack.Transport
|
||||
}
|
||||
finally
|
||||
{
|
||||
sendLock.Release();
|
||||
held.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,6 +235,63 @@ namespace Opc.Ua.Core.Tests.Stack.Transport
|
||||
Assert.That(harness.Target.Token.Secret, Is.EqualTo(harness.Peer.Token.Secret));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A response still queued for the dropped transport when a reconnect activates the
|
||||
/// new token is not secured with that token and sent: the reconnect detaches the old
|
||||
/// transport first, so the writer drops it without numbering it, and the
|
||||
/// OpenSecureChannel response is the first message on the new transport.
|
||||
/// </summary>
|
||||
[TestCase(SecurityPolicies.ECC_nistP256)]
|
||||
[TestCase(SecurityPolicies.RSA_DH_AesGcm)]
|
||||
public async Task AResponseQueuedForTheDroppedTransportIsNotSecuredWithTheNewTokenAsync(string policyUri)
|
||||
{
|
||||
if (!AssertPolicyAvailability(policyUri))
|
||||
{
|
||||
return;
|
||||
}
|
||||
using var harness = new HandoffHarness(policyUri);
|
||||
await harness.OpenAsync().ConfigureAwait(false);
|
||||
int sentOnOld = harness.OldTransport.SentCount;
|
||||
|
||||
// the writer sends one response on the old transport, and the next waits behind it.
|
||||
harness.OldTransport.HoldSends();
|
||||
harness.Target.SendResponseForTest(70, new ReadResponse());
|
||||
Assert.That(
|
||||
SpinWait.SpinUntil(() => harness.OldTransport.SentCount > sentOnOld, TimeSpan.FromSeconds(10)),
|
||||
Is.True);
|
||||
harness.Target.SendResponseForTest(71, new ReadResponse());
|
||||
|
||||
// the writer goes on once the reconnect has activated the new token.
|
||||
Action<ChannelToken?, ChannelToken?>? activated = harness.Target.TokenActivatedCallback;
|
||||
bool armed = true;
|
||||
harness.Target.TokenActivatedCallback = (current, previous) =>
|
||||
{
|
||||
activated?.Invoke(current, previous);
|
||||
if (armed)
|
||||
{
|
||||
armed = false;
|
||||
harness.OldTransport.ReleaseSends();
|
||||
Assert.That(
|
||||
SpinWait.SpinUntil(() => !harness.Target.HasPendingWritesForTest, TimeSpan.FromSeconds(10)),
|
||||
Is.True);
|
||||
}
|
||||
};
|
||||
|
||||
await harness.RenewAsync().ConfigureAwait(false);
|
||||
|
||||
Assert.That(harness.HandoffError, Is.Null);
|
||||
Assert.That(armed, Is.False, "the token was not activated.");
|
||||
Assert.That(
|
||||
harness.OldTransport.SentCount,
|
||||
Is.EqualTo(sentOnOld + 1),
|
||||
"the response queued for the dropped transport was sent with the new token.");
|
||||
await harness.Peer.CompleteOpenAsync(
|
||||
await harness.NewTransport.ReadAsync(harness.CancellationToken).ConfigureAwait(false),
|
||||
renew: true,
|
||||
reconnect: true).ConfigureAwait(false);
|
||||
await harness.AssertEncryptedReadAsync(harness.NewTransport).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
[TestCase(SecurityPolicies.ECC_nistP256, false)]
|
||||
[TestCase(SecurityPolicies.ECC_nistP256, true)]
|
||||
[TestCase(SecurityPolicies.RSA_DH_AesGcm, false)]
|
||||
@@ -999,6 +1056,18 @@ namespace Opc.Ua.Core.Tests.Stack.Transport
|
||||
Assert.That(VerifySequenceNumber(sequenceNumber, "ReceivedBeforeReconnect"), Is.True);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Queues a response for the writer on the transport the channel has now.
|
||||
/// </summary>
|
||||
public void SendResponseForTest(uint requestId, IServiceResponse response)
|
||||
{
|
||||
EncodedSymmetricMessage encoded = EncodeSymmetricMessage(
|
||||
TcpMessageType.Message, requestId, response, false, out _);
|
||||
Assert.That(BeginWriteSymmetricMessage(encoded, null), Is.True);
|
||||
}
|
||||
|
||||
public bool HasPendingWritesForTest => HasPendingWrites;
|
||||
|
||||
protected internal override void StartReceiveLoop()
|
||||
{
|
||||
if (FailReceiveStart)
|
||||
@@ -1146,10 +1215,29 @@ namespace Opc.Ua.Core.Tests.Stack.Transport
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public ValueTask SendChunkAsync(ReadOnlyMemory<byte> chunk, CancellationToken ct)
|
||||
public async ValueTask SendChunkAsync(ReadOnlyMemory<byte> chunk, CancellationToken ct)
|
||||
{
|
||||
Interlocked.Increment(ref m_sentCount);
|
||||
return m_sent.Writer.WriteAsync(chunk.ToArray().ToByteString(), ct);
|
||||
ByteString sent = chunk.ToArray().ToByteString();
|
||||
TaskCompletionSource<bool>? hold = Volatile.Read(ref m_hold);
|
||||
if (hold != null)
|
||||
{
|
||||
await hold.Task.ConfigureAwait(false);
|
||||
}
|
||||
await m_sent.Writer.WriteAsync(sent, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Holds every send that starts from now on until <see cref="ReleaseSends"/>.
|
||||
/// </summary>
|
||||
public void HoldSends()
|
||||
{
|
||||
Volatile.Write(ref m_hold, new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously));
|
||||
}
|
||||
|
||||
public void ReleaseSends()
|
||||
{
|
||||
Interlocked.Exchange(ref m_hold, null)?.TrySetResult(true);
|
||||
}
|
||||
|
||||
public async ValueTask SendChunkAsync(BufferCollection buffers, CancellationToken ct)
|
||||
@@ -1187,6 +1275,7 @@ namespace Opc.Ua.Core.Tests.Stack.Transport
|
||||
}
|
||||
|
||||
private readonly Channel<ByteString> m_sent = Channel.CreateUnbounded<ByteString>();
|
||||
private TaskCompletionSource<bool>? m_hold;
|
||||
private int m_closed;
|
||||
private int m_sentCount;
|
||||
}
|
||||
|
||||
@@ -62,9 +62,11 @@ namespace Opc.Ua.Core.Tests.Stack.Transport
|
||||
var transport = new GateByteTransport(responseCount);
|
||||
channel.SetTransport(transport);
|
||||
|
||||
// responses above an eighth of the send buffer keep their send buffer;
|
||||
// smaller ones move to a buffer of their own size.
|
||||
for (uint requestId = 1; requestId <= responseCount; requestId++)
|
||||
{
|
||||
channel.SendResponse(requestId, CreateResponse());
|
||||
channel.SendResponse(requestId, CreateLargeResponse(16 * 1024));
|
||||
}
|
||||
|
||||
Assert.That(
|
||||
@@ -103,8 +105,7 @@ namespace Opc.Ua.Core.Tests.Stack.Transport
|
||||
Assert.That(
|
||||
await WaitForOutstandingCountAsync(pool, expected: 0, 30).ConfigureAwait(false),
|
||||
Is.True);
|
||||
Assert.That(pool.RentCount, Is.EqualTo(1));
|
||||
Assert.That(pool.ReturnCount, Is.EqualTo(1));
|
||||
Assert.That(pool.ReturnCount, Is.EqualTo(pool.RentCount));
|
||||
Assert.That(pool.DuplicateReturnCount, Is.Zero);
|
||||
}
|
||||
|
||||
@@ -223,8 +224,7 @@ namespace Opc.Ua.Core.Tests.Stack.Transport
|
||||
Assert.That(
|
||||
await WaitForOutstandingCountAsync(pool, expected: 0, 30).ConfigureAwait(false),
|
||||
Is.True);
|
||||
Assert.That(pool.RentCount, Is.EqualTo(1));
|
||||
Assert.That(pool.ReturnCount, Is.EqualTo(1));
|
||||
Assert.That(pool.ReturnCount, Is.EqualTo(pool.RentCount));
|
||||
Assert.That(pool.DuplicateReturnCount, Is.Zero);
|
||||
}
|
||||
|
||||
@@ -248,8 +248,7 @@ namespace Opc.Ua.Core.Tests.Stack.Transport
|
||||
Assert.That(
|
||||
await WaitForOutstandingCountAsync(pool, expected: 0, 30).ConfigureAwait(false),
|
||||
Is.True);
|
||||
Assert.That(pool.RentCount, Is.EqualTo(1));
|
||||
Assert.That(pool.ReturnCount, Is.EqualTo(1));
|
||||
Assert.That(pool.ReturnCount, Is.EqualTo(pool.RentCount));
|
||||
Assert.That(pool.DuplicateReturnCount, Is.Zero);
|
||||
}
|
||||
|
||||
@@ -337,7 +336,9 @@ namespace Opc.Ua.Core.Tests.Stack.Transport
|
||||
var transport = new GateByteTransport(expectedSendCount: 1);
|
||||
channel.SetTransport(transport);
|
||||
|
||||
channel.SendResponse(1, CreateResponse());
|
||||
// large enough to keep the send buffer (smaller responses move to a
|
||||
// buffer of their own size).
|
||||
channel.SendResponse(1, CreateLargeResponse(16 * 1024));
|
||||
|
||||
Assert.That(
|
||||
await CompletesWithinAsync(transport.AllSendsStarted, 30).ConfigureAwait(false),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,122 @@
|
||||
/* ========================================================================
|
||||
* Copyright (c) 2005-2026 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/
|
||||
* ======================================================================*/
|
||||
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using NUnit.Framework;
|
||||
using Opc.Ua.Tests;
|
||||
|
||||
namespace Opc.Ua.Types.Tests.Utils
|
||||
{
|
||||
/// <summary>
|
||||
/// The namespace and server tables of a message context are read without a
|
||||
/// lock; a reader that compares the table and the mapping version it read
|
||||
/// earlier with the current ones must still detect every replacement.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
[Category("Utils")]
|
||||
[Parallelizable]
|
||||
public sealed class ServiceMessageContextMappingTests
|
||||
{
|
||||
[Test]
|
||||
public void ReplacingATableAdvancesTheMappingVersion()
|
||||
{
|
||||
var context = ServiceMessageContext.CreateEmpty(NUnitTelemetryContext.Create());
|
||||
long version = context.MappingVersion;
|
||||
NamespaceTable first = context.NamespaceUris;
|
||||
|
||||
context.NamespaceUris = first;
|
||||
Assert.That(context.MappingVersion, Is.EqualTo(version), "setting the same table is no change.");
|
||||
|
||||
var second = new NamespaceTable();
|
||||
context.NamespaceUris = second;
|
||||
Assert.That(context.NamespaceUris, Is.SameAs(second));
|
||||
Assert.That(context.MappingVersion, Is.EqualTo(version + 1));
|
||||
|
||||
var servers = new StringTable();
|
||||
context.ServerUris = servers;
|
||||
Assert.That(context.ServerUris, Is.SameAs(servers));
|
||||
Assert.That(context.MappingVersion, Is.EqualTo(version + 2));
|
||||
|
||||
// back to the first table: the reference matches again, the version does not.
|
||||
context.NamespaceUris = first;
|
||||
Assert.That(context.NamespaceUris, Is.SameAs(first));
|
||||
Assert.That(context.MappingVersion, Is.EqualTo(version + 3));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ReadersDetectEveryReplacementWhileTablesAreSwappedAsync()
|
||||
{
|
||||
const int swaps = 20000;
|
||||
var context = ServiceMessageContext.CreateEmpty(NUnitTelemetryContext.Create());
|
||||
NamespaceTable a = context.NamespaceUris;
|
||||
var b = new NamespaceTable();
|
||||
long initial = context.MappingVersion;
|
||||
int done = 0;
|
||||
int violations = 0;
|
||||
|
||||
Task[] readers = [.. Enumerable.Range(0, 4).Select(_ => Task.Run(() =>
|
||||
{
|
||||
long lastVersion = initial;
|
||||
while (Volatile.Read(ref done) == 0)
|
||||
{
|
||||
// version, table, version: when the version did not move, the
|
||||
// table read in between is the one that version belongs to
|
||||
// (the swaps alternate, so odd versions are b and even ones a).
|
||||
long before = context.MappingVersion;
|
||||
NamespaceTable table = context.NamespaceUris;
|
||||
long after = context.MappingVersion;
|
||||
if (before < lastVersion || after < before)
|
||||
{
|
||||
Interlocked.Increment(ref violations);
|
||||
}
|
||||
lastVersion = after;
|
||||
|
||||
if (before == after &&
|
||||
!ReferenceEquals(table, (before - initial) % 2 == 1 ? b : a))
|
||||
{
|
||||
Interlocked.Increment(ref violations);
|
||||
}
|
||||
}
|
||||
}))];
|
||||
|
||||
for (int ii = 0; ii < swaps; ii++)
|
||||
{
|
||||
context.NamespaceUris = ii % 2 == 0 ? b : a;
|
||||
}
|
||||
Volatile.Write(ref done, 1);
|
||||
await Task.WhenAll(readers).ConfigureAwait(false);
|
||||
|
||||
Assert.That(violations, Is.Zero);
|
||||
Assert.That(context.MappingVersion, Is.EqualTo(initial + swaps));
|
||||
Assert.That(context.NamespaceUris, Is.SameAs(a));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user