mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-17 05:11:13 +08:00
3a31473cf4
# Description
Every request passes through `SessionManager.TryGetSessionContext` at
admission and again at dispatch
(`FairRequestQueue.GetRevalidationStatus` →
`DefaultServerResourceIsolationProvider` →
`ISessionBindingProvider.TryGetSessionContext`), and then through
`Session.ValidateRequest`. Together they took:
1. the server-wide `m_bindingsLock`, to confirm the candidate binding
was still the committed one;
2. the per-session diagnostics lock in `Session.HasExpired`. It is
inlined, so CPU traces charge its spin to `TryGetSessionContext`;
3. the session lock in `Session.IsSecureChannelValid`;
4. the session lock around all of `Session.ValidateRequest`, plus the
diagnostics lock twice (contact refresh, diagnostic counters);
5. the server-wide `SessionManager` event lock, to raise
`SessionDiagnosticsChanged` for every request even when nothing
subscribes.
With the FairRequestQueue lock also removed (a local change without the
queue-wide lock, not yet a PR), these were the next waiting points:
14–16.5 % of on-CPU samples spun in `Lock.TryEnterSlow` under
`TryGetSessionContext`, and once that was fixed another 7–10 % under
`Session.ValidateRequest`.
## Changes
**SessionManager: confirm the binding without the bindings lock**
- `SessionActivationState` publishes a `DispatchBinding` (the committed
binding while no activation is committing, otherwise `null`) with
`Volatile.Write`. The `BindingContext` and `IsCommitting` setters
republish it, and every writer of those already runs under
`m_bindingsLock`.
- The lookup reads it once for the candidate and once to confirm. A
commit always creates a new `SessionBindingContext`, so an unchanged
reference means nothing replaced it. A token is mapped to a session
object at most once and is never re-added after removal, so finding the
token still mapped to the same session afterwards means both held at the
instant of the confirming read, which is the guarantee the lock gave.
- IsCommitting, transfer, close/claim, shutdown and the
certificate/security checks are unchanged.
**Session: read the channel id without the session lock**
- `SecureChannelId` is published with a volatile write in `Activate`,
and `IsSecureChannelValid` reads it without the session lock.
**Session: request validation and diagnostic counters without locks**
- Request counts are interlocked increments of per-session slots (total
and error per `RequestType`, plus unauthorized). They are applied to
`SessionDiagnostics` under the diagnostics lock whenever it is read
(`ReadDiagnostics`, `UpdateDiagnostics`, the diagnostics-node callback,
`ClientLastContactTime`), so readers see the same totals.
- The contact refresh is a compare-and-swap on the tick count, and
requests within the same millisecond do not write. An expiry observed by
`HasExpired` or by a refresh is recorded in the contact word
(`kExpiredFlag`), so a request that read the clock before the monitor
saw the timeout cannot refresh it afterwards. This keeps the documented
"a request cannot revive a session the monitor saw expire" guarantee
without the lock. The intermediate commit that made `HasExpired`
lock-free on its own had opened that window; this commit closes it.
- `ValidateRequest` takes no session lock: the channel id and
`Activated` are published with volatile writes.
- `RaiseSessionEvent` returns before taking the event lock when a
`DiagnosticsChanged` event has no subscriber.
Behaviour differences reviewers should know about:
- `ClientLastContactTime` is millisecond-granular, and two concurrent
refreshes can publish their wall-clock times in reverse order, which
leaves it up to about 1 ms older than the latest contact.
- After an expiry has been observed, an `Activate` no longer refreshes
the contact time. `SessionManager` already rejects activation of an
expired session before that point.
## Tests
- `LookupDuringActivationCommitReturnsNoBindingAsync`: a lookup inside
the commit window (after `Session.Activate`, before the binding commit)
returns no binding. A test session re-implements `ISession.Activate` to
run the hook. Before this test, publishing the binding regardless of
`IsCommitting` passed all 690 session tests.
- `LookupRacingTransfersAndCloseReturnsOnlyCommittedBindingsAsync`: 4
readers look up while 1000 transfers alternate between two channels,
followed by a close. Each result must be a committed binding of the
queried channel, no older than the last completed activation and never
decreasing, and nothing may come back after the close.
- `RequestRacingObservedExpiryDoesNotReviveTheSessionAsync`: a clock
hook lets the monitor observe the expiry between a request's clock read
and its refresh, and the request must fail with `Bad_SessionClosed`. It
fails if `HasExpired` does not record the expiry.
- `ConcurrentRequestsAreCountedExactlyAsync`: 8 threads × 2100
validations (Read/Browse, plus rejected Writes). The totals, per-service
counts, error counts and unauthorized count must be exact, and a
concurrent reader never sees the total go backwards.
- Local runs (net10.0): `Opc.Ua.Server.Tests` 8487 passed / 0 failed;
`Opc.Ua.Sessions.Tests` 851 passed / 0 failed / 379 skipped. net48
builds.
## Measurements
`perf/ServerLoadHarness`, Server GC, paired alternating A/B, 7 rounds.
Baseline is master 8b360da7d + that FairRequestQueue change; candidate
adds the three commits.
| scenario | CPU µs/req | alloc B/req | lock contentions/req | req/s |
|---|---|---|---|---|
| read 1 node, 10 sessions × 100 in flight | 25.4 → 24.1 (−5 %) | 5456 →
5455 | −66 % | +1 % |
| read 1 node, 200 sessions | 42.9 → 43.7 (+2 %) | 5225 → 5212 | −60 % |
−5 % |
CPU and throughput differences are within this shared machine's
run-to-run noise (CPU ranges overlap on both sides). Allocation and
contention per request are the stable signals.
CPU trace (10 × 100), share of on-CPU samples spinning in
`Lock.TryEnterSlow`, 2 runs each:
| build | total | under `TryGetSessionContext` | under
`Session.ValidateRequest` |
|---|---|---|---|
| baseline | 20.8 / 23.7 % | 14.0 / 16.5 % | 0.8 / 0.9 % |
| + binding snapshot | 18.0 / 16.6 % | 12.5 / 11.3 % (inlined
`HasExpired`) | 0.9 / 0.7 % |
| + lock-free `HasExpired` / channel id | 9.5–13.5 % | 0 | 4.8–9.9 % |
| + lock-free validation and counters | 3.5 / 3.4 % | 0 | 0 |
The spin moves rather than disappearing: `Monitor.Enter_Slowpath`, from
the channel locks in `TcpServerChannel.HandleIncomingMessageAsync` /
`SendResponseAsync` and `ChannelGate`, rises from 37–40 % to about 47 %.
That is the next waiting point.
On master alone (FairRequestQueue lock still in place), CPU/req and
alloc/req changes are within noise.
## Related Issues
- Follow-up to the o6 benchmark gap work (#4742, #4743).
## Checklist
- [ ] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [x] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [x] I have added all necessary documentation.
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed.
(Opc.Ua.Server.Tests and Opc.Ua.Sessions.Tests on net10.0; net48
compiles)
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [ ] I have addressed **all** PR feedback received.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1125 lines
53 KiB
C#
1125 lines
53 KiB
C#
/* ========================================================================
|
|
* Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
|
|
*
|
|
* OPC Foundation MIT License 1.00
|
|
*
|
|
* Permission is hereby granted, free of charge, to any person
|
|
* obtaining a copy of this software and associated documentation
|
|
* files (the "Software"), to deal in the Software without
|
|
* restriction, including without limitation the rights to use,
|
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
* copies of the Software, and to permit persons to whom the
|
|
* Software is furnished to do so, subject to the following
|
|
* conditions:
|
|
*
|
|
* The above copyright notice and this permission notice shall be
|
|
* included in all copies or substantial portions of the Software.
|
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
|
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
|
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
|
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
|
* OTHER DEALINGS IN THE SOFTWARE.
|
|
*
|
|
* The complete license agreement can be found here:
|
|
* http://opcfoundation.org/License/MIT/1.00/
|
|
* ======================================================================*/
|
|
|
|
using System;
|
|
using System.Collections.Concurrent;
|
|
using System.Collections.Generic;
|
|
using System.Reflection;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Microsoft.Extensions.Time.Testing;
|
|
using Moq;
|
|
using NUnit.Framework;
|
|
using Opc.Ua.Identity;
|
|
using Opc.Ua.Security.Certificates;
|
|
using Opc.Ua.Tests;
|
|
using ServerSession = Opc.Ua.Server.Session;
|
|
|
|
namespace Opc.Ua.Server.Tests
|
|
{
|
|
/// <summary>
|
|
/// Regression tests for session lifecycle defects: expiry and closing at request
|
|
/// admission, activation racing a close, server-initiated terminations, cancellable
|
|
/// teardown, diagnostics privilege ordering and shutdown.
|
|
/// </summary>
|
|
[TestFixture]
|
|
[Category("Session")]
|
|
public sealed class SessionLifecycleRegressionTests
|
|
{
|
|
private const string kChannelId = "lc-channel";
|
|
private static readonly string[] s_timeoutAudit = ["Session/Timeout"];
|
|
private static readonly string[] s_terminatedAudit = ["Session/Terminated"];
|
|
|
|
/// <summary>
|
|
/// LC-1: a request arriving after the session timeout must not revive the
|
|
/// session; it is rejected and the session is terminated as timed out.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task RequestOnExpiredSessionIsRejectedAndClosesTheSessionAsync()
|
|
{
|
|
using var harness = new Harness();
|
|
CreateSessionResult created = await harness.CreateActivatedSessionAsync().ConfigureAwait(false);
|
|
|
|
harness.Clock.Advance(TimeSpan.FromSeconds(2));
|
|
Assert.That(created.Session.HasExpired, Is.True);
|
|
|
|
ServiceResultException ex = Assert.ThrowsAsync<ServiceResultException>(
|
|
async () => await harness.ValidateAsync(created.AuthenticationToken, RequestType.Read)
|
|
.ConfigureAwait(false))!;
|
|
|
|
Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadSessionClosed));
|
|
Assert.That(harness.Manager.GetSession(created.AuthenticationToken), Is.Null);
|
|
Assert.That(harness.Diagnostics.SessionTimeoutCount, Is.EqualTo(1u));
|
|
}
|
|
|
|
/// <summary>
|
|
/// LC-1: a request at the boundary must not refresh the contact time of a
|
|
/// session whose timeout has elapsed.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task RejectedRequestDoesNotReviveAnExpiredSessionAsync()
|
|
{
|
|
using var harness = new Harness();
|
|
CreateSessionResult created = await harness.CreateActivatedSessionAsync().ConfigureAwait(false);
|
|
var session = (ServerSession)created.Session;
|
|
var channel = new SecureChannelContext(kChannelId, harness.Endpoint, RequestEncoding.Binary);
|
|
|
|
harness.Clock.Advance(TimeSpan.FromSeconds(2));
|
|
|
|
Assert.That(
|
|
() => session.ValidateRequest(
|
|
new RequestHeader { AuthenticationToken = created.AuthenticationToken },
|
|
channel,
|
|
RequestType.Read),
|
|
Throws.TypeOf<ServiceResultException>()
|
|
.With.Property(nameof(ServiceResultException.StatusCode))
|
|
.EqualTo((StatusCode)StatusCodes.BadSessionClosed));
|
|
Assert.That(session.HasExpired, Is.True, "A rejected request must not refresh the contact time.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// LC-1: request validation refreshes the contact time without a lock. A request
|
|
/// that read the clock before the session monitor observed the expiry must still
|
|
/// not refresh the contact time afterwards and revive the session.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task RequestRacingObservedExpiryDoesNotReviveTheSessionAsync()
|
|
{
|
|
using var harness = new Harness();
|
|
CreateSessionResult created = await harness.CreateActivatedSessionAsync().ConfigureAwait(false);
|
|
var session = (ServerSession)created.Session;
|
|
var channel = new SecureChannelContext(kChannelId, harness.Endpoint, RequestEncoding.Binary);
|
|
harness.Clock.Advance(TimeSpan.FromMilliseconds(900));
|
|
bool? expiredInRace = null;
|
|
|
|
// The request reads the clock (900 ms, not expired); before it refreshes the
|
|
// contact time the timeout elapses and the monitor observes the expiry.
|
|
harness.Clock.AfterNextTimestamp(() =>
|
|
{
|
|
harness.Clock.Advance(TimeSpan.FromMilliseconds(200));
|
|
expiredInRace = session.HasExpired;
|
|
});
|
|
|
|
Assert.That(
|
|
() => session.ValidateRequest(
|
|
new RequestHeader { AuthenticationToken = created.AuthenticationToken },
|
|
channel,
|
|
RequestType.Read),
|
|
Throws.TypeOf<ServiceResultException>()
|
|
.With.Property(nameof(ServiceResultException.StatusCode))
|
|
.EqualTo((StatusCode)StatusCodes.BadSessionClosed));
|
|
Assert.That(expiredInRace, Is.True);
|
|
Assert.That(session.HasExpired, Is.True, "An observed expiry must stay observed.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// The request counters are updated without the diagnostics lock and applied when
|
|
/// the diagnostics are read: concurrent requests must all be counted exactly once,
|
|
/// and a reader never sees a count go backwards.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task ConcurrentRequestsAreCountedExactlyAsync()
|
|
{
|
|
const int kThreads = 8;
|
|
const int kRequests = 2000;
|
|
const int kRejected = 100;
|
|
using var harness = new Harness();
|
|
CreateSessionResult created = await harness.CreateActivatedSessionAsync().ConfigureAwait(false);
|
|
var session = (ServerSession)created.Session;
|
|
var channel = new SecureChannelContext(kChannelId, harness.Endpoint, RequestEncoding.Binary);
|
|
var otherChannel = new SecureChannelContext("other-channel", harness.Endpoint, RequestEncoding.Binary);
|
|
var header = new RequestHeader { AuthenticationToken = created.AuthenticationToken };
|
|
(uint Total, uint Read) baseline = session.ReadDiagnostics(
|
|
d => (d.TotalRequestCount.TotalCount, d.ReadCount.TotalCount));
|
|
|
|
using var stop = new CancellationTokenSource();
|
|
Task<bool> reader = Task.Run(() =>
|
|
{
|
|
uint last = 0;
|
|
while (!stop.IsCancellationRequested)
|
|
{
|
|
(uint total, bool consistent) = session.ReadDiagnostics(d => (
|
|
d.TotalRequestCount.TotalCount,
|
|
d.WriteCount.ErrorCount <= d.WriteCount.TotalCount &&
|
|
d.UnauthorizedRequestCount <= d.TotalRequestCount.ErrorCount));
|
|
if (total < last || !consistent)
|
|
{
|
|
return false;
|
|
}
|
|
last = total;
|
|
}
|
|
return true;
|
|
});
|
|
var workers = new Task[kThreads];
|
|
for (int ii = 0; ii < kThreads; ii++)
|
|
{
|
|
workers[ii] = Task.Run(() =>
|
|
{
|
|
for (int jj = 0; jj < kRequests; jj++)
|
|
{
|
|
session.ValidateRequest(header, channel, (jj & 1) == 0 ? RequestType.Read : RequestType.Browse);
|
|
}
|
|
for (int jj = 0; jj < kRejected; jj++)
|
|
{
|
|
Assert.Throws<ServiceResultException>(
|
|
() => session.ValidateRequest(header, otherChannel, RequestType.Write));
|
|
}
|
|
});
|
|
}
|
|
await Task.WhenAll(workers).ConfigureAwait(false);
|
|
stop.Cancel();
|
|
Assert.That(await reader.ConfigureAwait(false), Is.True,
|
|
"The total request count went backwards, or an error was counted without its request.");
|
|
|
|
session.ReadDiagnostics(d =>
|
|
{
|
|
Assert.That(d.TotalRequestCount.TotalCount - baseline.Total,
|
|
Is.EqualTo((uint)(kThreads * (kRequests + kRejected))));
|
|
Assert.That(d.TotalRequestCount.ErrorCount, Is.EqualTo((uint)(kThreads * kRejected)));
|
|
Assert.That(d.UnauthorizedRequestCount, Is.EqualTo((uint)(kThreads * kRejected)));
|
|
Assert.That(d.ReadCount.TotalCount - baseline.Read, Is.EqualTo((uint)(kThreads * kRequests / 2)));
|
|
Assert.That(d.ReadCount.ErrorCount, Is.Zero);
|
|
Assert.That(d.BrowseCount.TotalCount, Is.EqualTo((uint)(kThreads * kRequests / 2)));
|
|
Assert.That(d.WriteCount.TotalCount, Is.EqualTo((uint)(kThreads * kRejected)));
|
|
Assert.That(d.WriteCount.ErrorCount, Is.EqualTo((uint)(kThreads * kRejected)));
|
|
return true;
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Without a diagnostics reader nothing applies the pending request counts, so the
|
|
/// recording request folds them before a packed field could carry into the next:
|
|
/// more rejected requests than the error field holds must still be counted exactly.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task PendingCountsFoldBeforeTheyOverflowWithoutAReaderAsync()
|
|
{
|
|
const int kRejected = 70_000;
|
|
using var harness = new Harness();
|
|
CreateSessionResult created = await harness.CreateActivatedSessionAsync().ConfigureAwait(false);
|
|
var session = (ServerSession)created.Session;
|
|
var otherChannel = new SecureChannelContext("other-channel", harness.Endpoint, RequestEncoding.Binary);
|
|
var header = new RequestHeader { AuthenticationToken = created.AuthenticationToken };
|
|
uint baseline = session.ReadDiagnostics(d => d.TotalRequestCount.TotalCount);
|
|
|
|
for (int ii = 0; ii < kRejected; ii++)
|
|
{
|
|
try
|
|
{
|
|
session.ValidateRequest(header, otherChannel, RequestType.Write);
|
|
}
|
|
catch (ServiceResultException)
|
|
{
|
|
}
|
|
}
|
|
|
|
session.ReadDiagnostics(d =>
|
|
{
|
|
Assert.That(d.TotalRequestCount.TotalCount - baseline, Is.EqualTo((uint)kRejected));
|
|
Assert.That(d.TotalRequestCount.ErrorCount, Is.EqualTo((uint)kRejected));
|
|
Assert.That(d.UnauthorizedRequestCount, Is.EqualTo((uint)kRejected));
|
|
Assert.That(d.WriteCount.TotalCount, Is.EqualTo((uint)kRejected));
|
|
Assert.That(d.WriteCount.ErrorCount, Is.EqualTo((uint)kRejected));
|
|
return true;
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// The expiry checks read the session timeout without the diagnostics lock; a
|
|
/// timeout changed through UpdateDiagnostics must still take effect for them.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task SessionTimeoutChangedThroughDiagnosticsAppliesToExpiryAsync()
|
|
{
|
|
using var harness = new Harness();
|
|
CreateSessionResult created = await harness.CreateActivatedSessionAsync().ConfigureAwait(false);
|
|
var session = (ServerSession)created.Session;
|
|
var channel = new SecureChannelContext(kChannelId, harness.Endpoint, RequestEncoding.Binary);
|
|
|
|
session.UpdateDiagnostics(d => d.ActualSessionTimeout = 5_000);
|
|
harness.Clock.Advance(TimeSpan.FromSeconds(2));
|
|
Assert.That(session.HasExpired, Is.False);
|
|
session.ValidateRequest(
|
|
new RequestHeader { AuthenticationToken = created.AuthenticationToken },
|
|
channel,
|
|
RequestType.Read);
|
|
|
|
session.UpdateDiagnostics(d => d.ActualSessionTimeout = 1_000);
|
|
harness.Clock.Advance(TimeSpan.FromSeconds(2));
|
|
Assert.That(session.HasExpired, Is.True);
|
|
}
|
|
|
|
/// <summary>
|
|
/// LC-1 / LC-2: a session that is being closed admits no new requests.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task RequestOnClosingSessionIsRejectedAsync()
|
|
{
|
|
using var harness = new Harness();
|
|
CreateSessionResult created = await harness.CreateActivatedSessionAsync().ConfigureAwait(false);
|
|
Assert.That(((ServerSession)created.Session).MarkClosing(), Is.True);
|
|
|
|
ServiceResultException ex = Assert.ThrowsAsync<ServiceResultException>(
|
|
async () => await harness.ValidateAsync(created.AuthenticationToken, RequestType.Read)
|
|
.ConfigureAwait(false))!;
|
|
|
|
Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadSessionClosed));
|
|
Assert.That(harness.Diagnostics.SessionTimeoutCount, Is.Zero, "A closing session is not a timeout.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// LC-3: ActivateSession on a session that a timeout or termination has started
|
|
/// closing must fail instead of returning Good for a session about to be removed.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task ActivateSessionOnClosingSessionFailsAsync()
|
|
{
|
|
using var harness = new Harness();
|
|
CreateSessionResult created = await harness.CreateActivatedSessionAsync().ConfigureAwait(false);
|
|
Assert.That(((ServerSession)created.Session).MarkClosing(), Is.True);
|
|
|
|
ServiceResultException ex = Assert.ThrowsAsync<ServiceResultException>(
|
|
async () => await harness.ActivateAsync(created.AuthenticationToken).ConfigureAwait(false))!;
|
|
|
|
Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadSessionClosed));
|
|
}
|
|
|
|
/// <summary>
|
|
/// LC-7: a request before ActivateSession makes the server terminate the session
|
|
/// through the regular close path, counted in SessionAbortCount.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task RequestBeforeActivationTerminatesAndCountsAnAbortAsync()
|
|
{
|
|
using var harness = new Harness();
|
|
CreateSessionResult created = await harness.CreateSessionAsync().ConfigureAwait(false);
|
|
|
|
ServiceResultException ex = Assert.ThrowsAsync<ServiceResultException>(
|
|
async () => await harness.ValidateAsync(created.AuthenticationToken, RequestType.Read)
|
|
.ConfigureAwait(false))!;
|
|
|
|
Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadSessionNotActivated));
|
|
harness.Server.Verify(
|
|
s => s.CloseSessionAsync(It.IsAny<OperationContext>(), created.SessionId, false, It.IsAny<CancellationToken>()),
|
|
Times.Once);
|
|
Assert.That(harness.Manager.GetSession(created.AuthenticationToken), Is.Null);
|
|
Assert.That(harness.Diagnostics.SessionAbortCount, Is.EqualTo(1u));
|
|
}
|
|
|
|
/// <summary>
|
|
/// LC-8: once the session is removed its teardown is not cancellable, so the
|
|
/// SessionDiagnostics node is always deleted.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task CloseSessionDeletesDiagnosticsWithAnUncancellableTokenAsync()
|
|
{
|
|
using var harness = new Harness();
|
|
CreateSessionResult created = await harness.CreateActivatedSessionAsync().ConfigureAwait(false);
|
|
using var cts = new CancellationTokenSource();
|
|
|
|
await harness.Manager.CloseSessionAsync(created.SessionId, cts.Token).ConfigureAwait(false);
|
|
|
|
Assert.That(harness.DeleteTokens, Has.Count.EqualTo(1));
|
|
Assert.That(harness.DeleteTokens[0].CanBeCanceled, Is.False);
|
|
Assert.That(harness.Diagnostics.CurrentSessionCount, Is.Zero);
|
|
}
|
|
|
|
/// <summary>
|
|
/// LC-9: the stale identity is re-evaluated before the return-diagnostics
|
|
/// privilege is decided.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task StaleIdentityIsReevaluatedBeforeDiagnosticsPrivilegeAsync()
|
|
{
|
|
using var harness = new Harness(recordOrder: true);
|
|
CreateSessionResult created = await harness.CreateActivatedSessionAsync().ConfigureAwait(false);
|
|
harness.Order.Clear();
|
|
|
|
using OperationContext context = await harness.ValidateAsync(
|
|
created.AuthenticationToken,
|
|
RequestType.Read).ConfigureAwait(false);
|
|
|
|
Assert.That(harness.Order, Has.Count.EqualTo(2));
|
|
Assert.That(harness.Order[0], Is.EqualTo("reevaluate"));
|
|
Assert.That(harness.Order[1], Is.EqualTo("diagnostics"));
|
|
}
|
|
|
|
/// <summary>
|
|
/// LC-10: sessions still open at shutdown are closed like any other session.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task ShutdownClosesRemainingSessionsAsync()
|
|
{
|
|
using var harness = new Harness();
|
|
CreateSessionResult created = await harness.CreateActivatedSessionAsync().ConfigureAwait(false);
|
|
int closing = 0;
|
|
harness.Manager.SessionClosing += (_, _) => Interlocked.Increment(ref closing);
|
|
|
|
await harness.Manager.ShutdownAsync().ConfigureAwait(false);
|
|
|
|
Assert.That(harness.Manager.GetSession(created.AuthenticationToken), Is.Null);
|
|
Assert.That(closing, Is.EqualTo(1));
|
|
Assert.That(harness.DeleteTokens, Has.Count.EqualTo(1), "The SessionDiagnostics node is removed.");
|
|
Assert.That(harness.Diagnostics.CurrentSessionCount, Is.Zero);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A session still open at shutdown is terminated by the server and audited once,
|
|
/// after its close completed and the session monitor stopped (OPC 10000-5 6.4.7).
|
|
/// </summary>
|
|
[Test]
|
|
public async Task ShutdownAuditsEachRemainingSessionOnceAsync()
|
|
{
|
|
using var harness = new Harness(auditing: true);
|
|
CreateSessionResult created = await harness.CreateActivatedSessionAsync().ConfigureAwait(false);
|
|
int closingAtAudit = -1;
|
|
int closing = 0;
|
|
harness.Manager.SessionClosing += (_, _) => Interlocked.Increment(ref closing);
|
|
harness.Server.Setup(s => s.ReportAuditEvent(It.IsAny<ISystemContext>(), It.IsAny<AuditEventState>()))
|
|
.Callback<ISystemContext, AuditEventState>((_, e) =>
|
|
{
|
|
lock (harness.Audits)
|
|
{
|
|
closingAtAudit = Volatile.Read(ref closing);
|
|
harness.Audits.Add(e.SourceName?.Value ?? string.Empty);
|
|
}
|
|
});
|
|
harness.Audits.Clear();
|
|
|
|
await harness.Manager.ShutdownAsync().ConfigureAwait(false);
|
|
|
|
Assert.That(harness.Audits, Is.EqualTo(s_terminatedAudit));
|
|
Assert.That(closingAtAudit, Is.EqualTo(1), "The termination is audited after the close.");
|
|
Assert.That(created.Session.IsClosing, Is.True);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A session that another close (a client close or a timeout) claimed before shutdown
|
|
/// is audited by that close, not again as terminated by the shutdown.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task ShutdownDoesNotAuditASessionAnotherCloseClaimedAsync()
|
|
{
|
|
using var harness = new Harness(auditing: true);
|
|
CreateSessionResult created = await harness.CreateActivatedSessionAsync().ConfigureAwait(false);
|
|
Assert.That(((ServerSession)created.Session).MarkClosing(), Is.True);
|
|
harness.Audits.Clear();
|
|
|
|
await harness.Manager.ShutdownAsync().ConfigureAwait(false);
|
|
|
|
Assert.That(harness.Audits, Is.Empty);
|
|
Assert.That(harness.Manager.GetSession(created.AuthenticationToken), Is.Null);
|
|
}
|
|
|
|
/// <summary>
|
|
/// LC-6: requests rejected by session validation are counted in the server's
|
|
/// RejectedRequestsCount, security rejections also in SecurityRejectedRequestsCount.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task ValidationRejectionsAreCountedInServerDiagnosticsAsync()
|
|
{
|
|
var clock = new FakeTimeProvider(DateTimeOffset.UtcNow);
|
|
var fixture = new Opc.Ua.Server.TestFramework.ServerFixture<SessionManagerExpiryTests.ExpiringSessionServer>(
|
|
t => new SessionManagerExpiryTests.ExpiringSessionServer(t, clock));
|
|
SessionManagerExpiryTests.ExpiringSessionServer server = await fixture.StartAsync().ConfigureAwait(false);
|
|
try
|
|
{
|
|
(RequestHeader requestHeader, SecureChannelContext secureChannelContext) =
|
|
await Opc.Ua.Server.TestFramework.ServerFixtureUtils
|
|
.CreateAndActivateSessionAsync(server, "RejectedCount")
|
|
.ConfigureAwait(false);
|
|
var serverInternal = (ServerInternalData)server.CurrentInstance;
|
|
uint rejectedBefore = serverInternal.ServerDiagnostics.RejectedRequestsCount;
|
|
uint securityRejectedBefore = serverInternal.ServerDiagnostics.SecurityRejectedRequestsCount;
|
|
ArrayOf<ReadValueId> nodesToRead =
|
|
[
|
|
new ReadValueId
|
|
{
|
|
NodeId = VariableIds.Server_ServerStatus_State,
|
|
AttributeId = Attributes.Value
|
|
}
|
|
];
|
|
|
|
// A token replayed from a different SecureChannel.
|
|
var otherChannel = new SecureChannelContext(
|
|
"other-channel",
|
|
secureChannelContext.EndpointDescription,
|
|
RequestEncoding.Binary);
|
|
ServiceResultException ex = Assert.ThrowsAsync<ServiceResultException>(
|
|
async () => await server.ReadAsync(
|
|
otherChannel,
|
|
new RequestHeader { AuthenticationToken = requestHeader.AuthenticationToken },
|
|
0,
|
|
TimestampsToReturn.Neither,
|
|
nodesToRead,
|
|
RequestLifetime.None).ConfigureAwait(false))!;
|
|
Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadSecureChannelIdInvalid));
|
|
|
|
Assert.That(
|
|
serverInternal.ServerDiagnostics.RejectedRequestsCount,
|
|
Is.EqualTo(rejectedBefore + 1));
|
|
Assert.That(
|
|
serverInternal.ServerDiagnostics.SecurityRejectedRequestsCount,
|
|
Is.EqualTo(securityRejectedBefore + 1));
|
|
|
|
await Opc.Ua.Server.TestFramework.ServerFixtureUtils
|
|
.CloseSessionAsync(server, secureChannelContext, requestHeader, CancellationToken.None)
|
|
.ConfigureAwait(false);
|
|
}
|
|
finally
|
|
{
|
|
await fixture.StopAsync().ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// K16: through ServerInternalData, concurrent terminations of one session close,
|
|
/// count and audit it once, and terminating a session that another close has
|
|
/// already claimed, or that no longer exists, counts nothing.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task TerminateSessionThroughServerInternalDataCountsOnceAsync()
|
|
{
|
|
var fixture = new Opc.Ua.Server.TestFramework.ServerFixture<StandardServer>(t => new StandardServer(t));
|
|
StandardServer server = await fixture.StartAsync().ConfigureAwait(false);
|
|
try
|
|
{
|
|
var serverInternal = (ServerInternalData)server.CurrentInstance;
|
|
Microsoft.Extensions.Logging.ILogger logger =
|
|
serverInternal.Telemetry.CreateLogger<SessionLifecycleRegressionTests>();
|
|
await Opc.Ua.Server.TestFramework.ServerFixtureUtils
|
|
.CreateAndActivateSessionAsync(server, "Terminate1")
|
|
.ConfigureAwait(false);
|
|
await Opc.Ua.Server.TestFramework.ServerFixtureUtils
|
|
.CreateAndActivateSessionAsync(server, "Terminate2")
|
|
.ConfigureAwait(false);
|
|
ISession[] sessions = [.. serverInternal.SessionManager.GetSessions()];
|
|
Assert.That(sessions, Has.Length.EqualTo(2));
|
|
uint abortsBefore = serverInternal.ServerDiagnostics.SessionAbortCount;
|
|
|
|
await Task.WhenAll(
|
|
serverInternal.TerminateSessionAsync(sessions[0].Id, false, logger).AsTask(),
|
|
serverInternal.TerminateSessionAsync(sessions[0].Id, false, logger).AsTask())
|
|
.ConfigureAwait(false);
|
|
await serverInternal.TerminateSessionAsync(sessions[0].Id, false, logger).ConfigureAwait(false);
|
|
|
|
Assert.That(sessions[0].IsClosing, Is.True);
|
|
Assert.That(serverInternal.SessionManager.GetSessions(), Does.Not.Contain(sessions[0]));
|
|
Assert.That(serverInternal.ServerDiagnostics.SessionAbortCount, Is.EqualTo(abortsBefore + 1));
|
|
|
|
// Another close has claimed the second session: the termination stays out of it.
|
|
Assert.That(((ServerSession)sessions[1]).MarkClosing(), Is.True);
|
|
await serverInternal.TerminateSessionAsync(sessions[1].Id, false, logger).ConfigureAwait(false);
|
|
Assert.That(serverInternal.ServerDiagnostics.SessionAbortCount, Is.EqualTo(abortsBefore + 1));
|
|
await serverInternal.SessionManager.CloseSessionAsync(sessions[1].Id).ConfigureAwait(false);
|
|
Assert.That(serverInternal.SessionManager.GetSessions(), Is.Empty);
|
|
}
|
|
finally
|
|
{
|
|
await fixture.StopAsync().ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// K17: a never-activated session whose timeout has also elapsed is closed as a
|
|
/// timeout, counted and audited once, not as an abort.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task RequestOnExpiredNeverActivatedSessionIsCountedAsATimeoutAsync()
|
|
{
|
|
using var harness = new Harness(auditing: true);
|
|
CreateSessionResult created = await harness.CreateSessionAsync().ConfigureAwait(false);
|
|
harness.Clock.Advance(TimeSpan.FromSeconds(2));
|
|
|
|
ServiceResultException ex = Assert.ThrowsAsync<ServiceResultException>(
|
|
async () => await harness.ValidateAsync(created.AuthenticationToken, RequestType.Read)
|
|
.ConfigureAwait(false))!;
|
|
|
|
Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadSessionNotActivated));
|
|
Assert.That(harness.Manager.GetSession(created.AuthenticationToken), Is.Null);
|
|
Assert.That(harness.Diagnostics.SessionTimeoutCount, Is.EqualTo(1u));
|
|
Assert.That(harness.Diagnostics.SessionAbortCount, Is.Zero);
|
|
Assert.That(harness.Audits, Is.EqualTo(s_timeoutAudit));
|
|
}
|
|
|
|
/// <summary>
|
|
/// K17: a session that another close has already claimed is not also counted
|
|
/// and audited as a timeout when its expiry is observed.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task ExpiryOfAClosingSessionIsNotCountedAsATimeoutAsync()
|
|
{
|
|
using var harness = new Harness(auditing: true);
|
|
CreateSessionResult created = await harness.CreateActivatedSessionAsync().ConfigureAwait(false);
|
|
harness.Audits.Clear();
|
|
Assert.That(((ServerSession)created.Session).MarkClosing(), Is.True);
|
|
harness.Clock.Advance(TimeSpan.FromSeconds(2));
|
|
|
|
ServiceResultException ex = Assert.ThrowsAsync<ServiceResultException>(
|
|
async () => await harness.ActivateAsync(created.AuthenticationToken).ConfigureAwait(false))!;
|
|
|
|
Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadSessionClosed));
|
|
Assert.That(harness.Diagnostics.SessionTimeoutCount, Is.Zero);
|
|
Assert.That(harness.Audits, Is.Empty);
|
|
}
|
|
|
|
/// <summary>
|
|
/// K4: closing the oldest non-activated session at the cap (Part 4 5.7.2.1) is a
|
|
/// termination by the server: counted in SessionAbortCount and audited once.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task CapEvictionIsCountedAndAuditedOnceAsync()
|
|
{
|
|
using var harness = new Harness(maxSessionCount: 1, auditing: true);
|
|
CreateSessionResult first = await harness.CreateSessionAsync().ConfigureAwait(false);
|
|
|
|
CreateSessionResult second = await harness.CreateSessionAsync().ConfigureAwait(false);
|
|
|
|
Assert.That(harness.Manager.GetSession(first.AuthenticationToken), Is.Null);
|
|
Assert.That(harness.Manager.GetSession(second.AuthenticationToken), Is.Not.Null);
|
|
Assert.That(harness.Diagnostics.SessionAbortCount, Is.EqualTo(1u));
|
|
Assert.That(harness.Audits, Is.EqualTo(s_terminatedAudit));
|
|
}
|
|
|
|
/// <summary>
|
|
/// K5 / K6: a session whose ActivateSession is in flight is not evicted at the
|
|
/// cap. CreateSession neither waits for the (possibly slow) authentication nor
|
|
/// turns the activation into a Good response for a session it then closes.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task CapEvictionSkipsASessionWithAnActivationInFlightAsync()
|
|
{
|
|
using var harness = new Harness(maxSessionCount: 1);
|
|
CreateSessionResult first = await harness.CreateSessionAsync().ConfigureAwait(false);
|
|
harness.Authenticator.Gate = new TaskCompletionSource<bool>(
|
|
TaskCreationOptions.RunContinuationsAsynchronously);
|
|
Task activation = harness.ActivateAsync(first.AuthenticationToken);
|
|
Assert.That(
|
|
await Task.WhenAny(harness.Authenticator.Entered.Task, Task.Delay(TimeSpan.FromSeconds(10)))
|
|
.ConfigureAwait(false),
|
|
Is.SameAs(harness.Authenticator.Entered.Task));
|
|
|
|
Task<CreateSessionResult> create = harness.CreateSessionAsync();
|
|
Task completed = await Task.WhenAny(create, Task.Delay(TimeSpan.FromSeconds(10))).ConfigureAwait(false);
|
|
harness.Authenticator.Gate.TrySetResult(true);
|
|
|
|
Assert.That(completed, Is.SameAs(create), "CreateSession waited for the activation.");
|
|
ServiceResultException ex = Assert.ThrowsAsync<ServiceResultException>(async () => await create.ConfigureAwait(false))!;
|
|
Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadTooManySessions));
|
|
await activation.ConfigureAwait(false);
|
|
Assert.That(first.Session.Activated, Is.True);
|
|
Assert.That(harness.Manager.GetSession(first.AuthenticationToken), Is.SameAs(first.Session));
|
|
}
|
|
|
|
/// <summary>
|
|
/// SWEEP-2: CloseSession on a session whose timeout elapsed before the session
|
|
/// monitor swept it is the client's close, not a timeout: it is admitted so the
|
|
/// requested DeleteSubscriptions is honoured. Other requests are still rejected.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task CloseSessionOnAnExpiredSessionIsAdmittedAsync()
|
|
{
|
|
using var harness = new Harness();
|
|
CreateSessionResult created = await harness.CreateActivatedSessionAsync().ConfigureAwait(false);
|
|
harness.Clock.Advance(TimeSpan.FromSeconds(2));
|
|
|
|
using OperationContext context = await harness.ValidateAsync(
|
|
created.AuthenticationToken,
|
|
RequestType.CloseSession).ConfigureAwait(false);
|
|
|
|
Assert.That(context.Session, Is.SameAs(created.Session));
|
|
Assert.That(harness.Diagnostics.SessionTimeoutCount, Is.Zero);
|
|
}
|
|
|
|
/// <summary>
|
|
/// SWEEP-2: CloseSession on a session that is already closing is still rejected.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task CloseSessionOnAClosingSessionIsRejectedAsync()
|
|
{
|
|
using var harness = new Harness();
|
|
CreateSessionResult created = await harness.CreateActivatedSessionAsync().ConfigureAwait(false);
|
|
Assert.That(((ServerSession)created.Session).MarkClosing(), Is.True);
|
|
|
|
ServiceResultException ex = Assert.ThrowsAsync<ServiceResultException>(
|
|
async () => await harness.ValidateAsync(created.AuthenticationToken, RequestType.CloseSession)
|
|
.ConfigureAwait(false))!;
|
|
|
|
Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadSessionClosed));
|
|
}
|
|
|
|
/// <summary>
|
|
/// PR review 4168294597: the session manager records that the client
|
|
/// certificate's validation error was accepted before it publishes the Session,
|
|
/// so a derived manager (e.g. one mirroring the Session after the base
|
|
/// CreateSessionAsync returns) never reads it as validated.
|
|
/// </summary>
|
|
[TestCase(false)]
|
|
[TestCase(true)]
|
|
public async Task AcceptedCertificateErrorIsRecordedBeforeTheSessionIsPublishedAsync(bool errorAccepted)
|
|
{
|
|
using var harness = new Harness();
|
|
CreateSessionResult created = await harness.CreateSessionAsync(certificateErrorAccepted: errorAccepted)
|
|
.ConfigureAwait(false);
|
|
|
|
Assert.That(ClientCertificateProvenance.IsValidated(created.Session), Is.EqualTo(!errorAccepted));
|
|
}
|
|
|
|
/// <summary>
|
|
/// PR review 4168294669: a CreateSession that is rejected with Bad_NonceInvalid
|
|
/// for reusing another Session's clientNonce does not close the oldest
|
|
/// non-activated Session at the cap to make room for itself.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task DuplicateClientNonceAtCapDoesNotEvictAsync()
|
|
{
|
|
using var harness = new Harness(maxSessionCount: 2, securityMode: MessageSecurityMode.SignAndEncrypt);
|
|
ByteString firstNonce = Nonce.CreateRandomNonceData(32).ToByteString();
|
|
ByteString secondNonce = Nonce.CreateRandomNonceData(32).ToByteString();
|
|
CreateSessionResult first = await harness.CreateSessionAsync(firstNonce).ConfigureAwait(false);
|
|
CreateSessionResult second = await harness.CreateSessionAsync(secondNonce).ConfigureAwait(false);
|
|
|
|
ServiceResultException ex = Assert.ThrowsAsync<ServiceResultException>(
|
|
async () => await harness.CreateSessionAsync(secondNonce).ConfigureAwait(false))!;
|
|
|
|
Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadNonceInvalid));
|
|
Assert.That(harness.Manager.GetSession(first.AuthenticationToken), Is.SameAs(first.Session));
|
|
Assert.That(harness.Manager.GetSession(second.AuthenticationToken), Is.SameAs(second.Session));
|
|
Assert.That(first.Session.IsClosing, Is.False);
|
|
Assert.That(harness.Diagnostics.SessionAbortCount, Is.Zero);
|
|
}
|
|
|
|
/// <summary>
|
|
/// PR review 4168294838: a custom <see cref="ISession"/> has no closing mark the
|
|
/// server can set, so a close that claimed it is recorded beside it. Request
|
|
/// admission still rejects it with Bad_SessionClosed, like a closing Session.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task RequestOnClaimedCustomSessionIsRejectedAsync()
|
|
{
|
|
using var harness = new Harness();
|
|
var custom = new Mock<ISession>();
|
|
custom.SetupGet(s => s.Id).Returns(new NodeId(4242u, 1));
|
|
custom.SetupGet(s => s.Activated).Returns(true);
|
|
var token = new NodeId(Nonce.CreateRandomNonceData(32).ToByteString());
|
|
harness.AddSession(token, custom.Object);
|
|
|
|
using (OperationContext admitted = await harness.ValidateAsync(token, RequestType.Read).ConfigureAwait(false))
|
|
{
|
|
Assert.That(admitted.Session, Is.SameAs(custom.Object));
|
|
}
|
|
Assert.That(SessionTermination.IsClosingOrClaimed(custom.Object), Is.False);
|
|
|
|
Assert.That(SessionTermination.TryClaimClose(custom.Object), Is.True);
|
|
|
|
Assert.That(custom.Object.IsClosing, Is.False);
|
|
Assert.That(SessionTermination.IsClosingOrClaimed(custom.Object), Is.True);
|
|
ServiceResultException ex = Assert.ThrowsAsync<ServiceResultException>(
|
|
async () => await harness.ValidateAsync(token, RequestType.Read).ConfigureAwait(false))!;
|
|
Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadSessionClosed));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Accepts anonymous tokens like <see cref="AnonymousAuthenticator"/>, optionally
|
|
/// holding the authentication until the test opens the gate.
|
|
/// </summary>
|
|
private sealed class GatedAnonymousAuthenticator : IUserTokenAuthenticator
|
|
{
|
|
public UserTokenType TokenType => UserTokenType.Anonymous;
|
|
|
|
public string? IssuedTokenProfileUri => null;
|
|
|
|
public TaskCompletionSource<bool>? Gate { get; set; }
|
|
|
|
public TaskCompletionSource<bool> Entered { get; } =
|
|
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
|
|
public async ValueTask<AuthenticationResult> AuthenticateAsync(
|
|
AuthenticationContext context,
|
|
CancellationToken ct = default)
|
|
{
|
|
Entered.TrySetResult(true);
|
|
if (Gate is TaskCompletionSource<bool> gate)
|
|
{
|
|
await gate.Task.ConfigureAwait(false);
|
|
}
|
|
return await m_inner.AuthenticateAsync(context, ct).ConfigureAwait(false);
|
|
}
|
|
|
|
private readonly AnonymousAuthenticator m_inner = new();
|
|
}
|
|
|
|
/// <summary>
|
|
/// A fake clock that can run a callback right after the next timestamp was read,
|
|
/// i.e. between a caller reading the clock and acting on the value.
|
|
/// </summary>
|
|
private sealed class HookClock : FakeTimeProvider
|
|
{
|
|
public override long GetTimestamp()
|
|
{
|
|
long timestamp = base.GetTimestamp();
|
|
Interlocked.Exchange(ref m_afterNextTimestamp, null)?.Invoke();
|
|
return timestamp;
|
|
}
|
|
|
|
public void AfterNextTimestamp(Action callback)
|
|
{
|
|
m_afterNextTimestamp = callback ?? throw new ArgumentNullException(nameof(callback));
|
|
}
|
|
|
|
private Action? m_afterNextTimestamp;
|
|
}
|
|
|
|
/// <summary>
|
|
/// A session manager over a mocked server whose clock, diagnostics and close path
|
|
/// are observable.
|
|
/// </summary>
|
|
private sealed class Harness : IDisposable
|
|
{
|
|
public Harness(
|
|
bool recordOrder = false,
|
|
int maxSessionCount = 10,
|
|
bool auditing = false,
|
|
MessageSecurityMode securityMode = MessageSecurityMode.None)
|
|
{
|
|
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
|
|
Server.Setup(s => s.Telemetry).Returns(telemetry);
|
|
Server.Setup(s => s.NamespaceUris).Returns(new NamespaceTable());
|
|
Server.Setup(s => s.MessageContext).Returns(ServiceMessageContext.CreateEmpty(telemetry));
|
|
Server.Setup(s => s.IdentityRegistry)
|
|
.Returns(new ServerIdentityRegistry(Authenticator));
|
|
if (auditing)
|
|
{
|
|
Server.Setup(s => s.Auditing).Returns(true);
|
|
Server.Setup(s => s.DefaultAuditContext).Returns(() => Server.Object.DefaultSystemContext);
|
|
Server.Setup(s => s.ReportAuditEvent(It.IsAny<ISystemContext>(), It.IsAny<AuditEventState>()))
|
|
.Callback<ISystemContext, AuditEventState>((_, e) =>
|
|
{
|
|
lock (Audits)
|
|
{
|
|
Audits.Add(e.SourceName?.Value ?? string.Empty);
|
|
}
|
|
});
|
|
}
|
|
Server.Setup(s => s.UpdateServerDiagnostics(It.IsAny<Action<ServerDiagnosticsSummaryDataType>>()))
|
|
.Callback<Action<ServerDiagnosticsSummaryDataType>>(update =>
|
|
{
|
|
lock (Diagnostics)
|
|
{
|
|
update(Diagnostics);
|
|
}
|
|
});
|
|
var diagnostics = new Mock<IDiagnosticsNodeManager>();
|
|
int nextSessionId = 0;
|
|
diagnostics.Setup(d => d.CreateSessionDiagnosticsAsync(
|
|
It.IsAny<ServerSystemContext>(),
|
|
It.IsAny<SessionDiagnosticsDataType>(),
|
|
It.IsAny<NodeValueSimpleEventHandler>(),
|
|
It.IsAny<SessionSecurityDiagnosticsDataType>(),
|
|
It.IsAny<NodeValueSimpleEventHandler>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.Returns(() => new ValueTask<NodeId>(
|
|
new NodeId((uint)Interlocked.Increment(ref nextSessionId), 1)));
|
|
diagnostics.Setup(d => d.DeleteSessionDiagnosticsAsync(
|
|
It.IsAny<ServerSystemContext>(),
|
|
It.IsAny<NodeId>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.Callback<ServerSystemContext, NodeId, CancellationToken>((_, _, ct) => DeleteTokens.Add(ct))
|
|
.Returns(default(ValueTask));
|
|
Server.Setup(s => s.DiagnosticsNodeManager).Returns(diagnostics.Object);
|
|
Server.Setup(s => s.DefaultSystemContext).Returns(new ServerSystemContext(Server.Object));
|
|
|
|
var configuration = new ApplicationConfiguration
|
|
{
|
|
ServerConfiguration = new ServerConfiguration
|
|
{
|
|
MinSessionTimeout = 1_000,
|
|
MaxSessionTimeout = 60_000,
|
|
MaxSessionCount = maxSessionCount,
|
|
MaxBrowseContinuationPoints = 10,
|
|
MaxHistoryContinuationPoints = 10
|
|
}
|
|
};
|
|
m_certificate = DefaultCertificateFactory.Instance
|
|
.CreateApplicationCertificate(
|
|
"urn:opcfoundation:test:session-lifecycle",
|
|
"SessionLifecycle",
|
|
"CN=SessionLifecycle",
|
|
["localhost"])
|
|
.SetRSAKeySize(2048)
|
|
.CreateForRSA();
|
|
Manager = recordOrder
|
|
? new OrderRecordingSessionManager(Server.Object, configuration, Clock, Order)
|
|
: new SessionManager(Server.Object, configuration, Clock);
|
|
Server.Setup(s => s.SessionManager).Returns(Manager);
|
|
|
|
// The timeout and termination paths close through the server; route them to
|
|
// the session manager as ServerInternalData does.
|
|
Server.Setup(s => s.CloseSessionAsync(
|
|
It.IsAny<OperationContext>(),
|
|
It.IsAny<NodeId>(),
|
|
It.IsAny<bool>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.Returns<OperationContext, NodeId, bool, CancellationToken>(
|
|
(_, id, _, ct) => Manager.CloseSessionAsync(id, ct));
|
|
|
|
Endpoint = new EndpointDescription
|
|
{
|
|
EndpointUrl = "opc.tcp://localhost/lifecycle",
|
|
SecurityPolicyUri = securityMode == MessageSecurityMode.None
|
|
? SecurityPolicies.None
|
|
: SecurityPolicies.Basic256Sha256,
|
|
SecurityMode = securityMode,
|
|
UserIdentityTokens =
|
|
[
|
|
new UserTokenPolicy
|
|
{
|
|
PolicyId = "anonymous",
|
|
TokenType = UserTokenType.Anonymous,
|
|
SecurityPolicyUri = SecurityPolicies.None
|
|
}
|
|
]
|
|
};
|
|
}
|
|
|
|
public Mock<IServerInternal> Server { get; } = new();
|
|
|
|
public HookClock Clock { get; } = new();
|
|
|
|
public SessionManager Manager { get; }
|
|
|
|
public EndpointDescription Endpoint { get; }
|
|
|
|
public ServerDiagnosticsSummaryDataType Diagnostics { get; } = new();
|
|
|
|
public List<CancellationToken> DeleteTokens { get; } = [];
|
|
|
|
public List<string> Order { get; } = [];
|
|
|
|
public List<string> Audits { get; } = [];
|
|
|
|
public GatedAnonymousAuthenticator Authenticator { get; } = new();
|
|
|
|
public async Task<CreateSessionResult> CreateSessionAsync(
|
|
ByteString clientNonce = default,
|
|
bool certificateErrorAccepted = false)
|
|
{
|
|
OperationContext context = CreateContext(RequestType.CreateSession);
|
|
context.ClientCertificateErrorAccepted = certificateErrorAccepted;
|
|
// The session owns the client certificate once created; a rejected
|
|
// CreateSession leaves it with the caller.
|
|
Certificate clientCertificate = m_certificate.AddRef();
|
|
CreateSessionResult created;
|
|
try
|
|
{
|
|
created = await Manager.CreateSessionAsync(
|
|
context,
|
|
m_certificate,
|
|
"lifecycle-regression",
|
|
clientNonce.IsEmpty ? ByteString.From(new byte[32]) : clientNonce,
|
|
new ApplicationDescription
|
|
{
|
|
ApplicationUri = "urn:opcfoundation:test:session-lifecycle"
|
|
},
|
|
Endpoint.EndpointUrl,
|
|
clientCertificate,
|
|
[],
|
|
1_000,
|
|
64 * 1024,
|
|
CancellationToken.None).ConfigureAwait(false);
|
|
}
|
|
catch
|
|
{
|
|
clientCertificate.Dispose();
|
|
throw;
|
|
}
|
|
// SessionManager leaves CurrentSessionCount to its caller.
|
|
Diagnostics.CurrentSessionCount++;
|
|
return created;
|
|
}
|
|
|
|
public async Task<CreateSessionResult> CreateActivatedSessionAsync()
|
|
{
|
|
CreateSessionResult created = await CreateSessionAsync().ConfigureAwait(false);
|
|
await ActivateAsync(created.AuthenticationToken).ConfigureAwait(false);
|
|
return created;
|
|
}
|
|
|
|
public async Task ActivateAsync(NodeId authenticationToken)
|
|
{
|
|
await Manager.ActivateSessionAsync(
|
|
CreateContext(RequestType.ActivateSession),
|
|
authenticationToken,
|
|
new SignatureData(),
|
|
default,
|
|
new SignatureData(),
|
|
[],
|
|
CancellationToken.None).ConfigureAwait(false);
|
|
}
|
|
|
|
public async Task<OperationContext> ValidateAsync(NodeId authenticationToken, RequestType requestType)
|
|
{
|
|
return await Manager.ValidateRequestAsync(
|
|
new RequestHeader { AuthenticationToken = authenticationToken },
|
|
new SecureChannelContext(kChannelId, Endpoint, RequestEncoding.Binary),
|
|
requestType,
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
}
|
|
|
|
public void AddSession(NodeId authenticationToken, ISession session)
|
|
{
|
|
var sessions = (ConcurrentDictionary<NodeId, ISession>)typeof(SessionManager)
|
|
.GetField("m_sessions", BindingFlags.Instance | BindingFlags.NonPublic)!
|
|
.GetValue(Manager)!;
|
|
Assert.That(sessions.TryAdd(authenticationToken, session), Is.True);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
Manager.Dispose();
|
|
m_certificate.Dispose();
|
|
}
|
|
|
|
private OperationContext CreateContext(RequestType requestType)
|
|
{
|
|
return new OperationContext(
|
|
new RequestHeader(),
|
|
new SecureChannelContext(kChannelId, Endpoint, RequestEncoding.Binary),
|
|
requestType,
|
|
RequestLifetime.None);
|
|
}
|
|
|
|
private readonly Certificate m_certificate;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Records the order of identity re-evaluation and the diagnostics privilege check.
|
|
/// </summary>
|
|
private sealed class OrderRecordingSessionManager : SessionManager
|
|
{
|
|
public OrderRecordingSessionManager(
|
|
IServerInternal server,
|
|
ApplicationConfiguration configuration,
|
|
TimeProvider clock,
|
|
List<string> order)
|
|
: base(server, configuration, clock)
|
|
{
|
|
m_order = order;
|
|
}
|
|
|
|
protected override void ReevaluateIdentityIfStale(
|
|
ISession session,
|
|
SecureChannelContext secureChannelContext)
|
|
{
|
|
m_order.Add("reevaluate");
|
|
base.ReevaluateIdentityIfStale(session, secureChannelContext);
|
|
}
|
|
|
|
protected override ISession CreateSession(
|
|
OperationContext context,
|
|
IServerInternal server,
|
|
Certificate serverCertificate,
|
|
NodeId sessionCookie,
|
|
ByteString clientNonce,
|
|
Nonce serverNonce,
|
|
string sessionName,
|
|
ApplicationDescription clientDescription,
|
|
string endpointUrl,
|
|
Certificate clientCertificate,
|
|
CertificateCollection clientCertificateChain,
|
|
double sessionTimeout,
|
|
uint maxResponseMessageSize,
|
|
int maxRequestAge,
|
|
int maxContinuationPoints)
|
|
{
|
|
return new OrderRecordingSession(
|
|
context,
|
|
server,
|
|
serverCertificate,
|
|
sessionCookie,
|
|
clientNonce,
|
|
serverNonce,
|
|
sessionName,
|
|
clientDescription,
|
|
endpointUrl,
|
|
clientCertificate,
|
|
clientCertificateChain,
|
|
sessionTimeout,
|
|
m_order);
|
|
}
|
|
|
|
private readonly List<string> m_order;
|
|
}
|
|
|
|
private sealed class OrderRecordingSession : ServerSession
|
|
{
|
|
public OrderRecordingSession(
|
|
OperationContext context,
|
|
IServerInternal server,
|
|
Certificate serverCertificate,
|
|
NodeId authenticationToken,
|
|
ByteString clientNonce,
|
|
Nonce serverNonce,
|
|
string sessionName,
|
|
ApplicationDescription clientDescription,
|
|
string endpointUrl,
|
|
Certificate clientCertificate,
|
|
CertificateCollection clientCertificateChain,
|
|
double sessionTimeout,
|
|
List<string> order)
|
|
: base(
|
|
context,
|
|
server,
|
|
serverCertificate,
|
|
authenticationToken,
|
|
clientNonce,
|
|
serverNonce,
|
|
sessionName,
|
|
clientDescription,
|
|
endpointUrl,
|
|
clientCertificate,
|
|
clientCertificateChain,
|
|
sessionTimeout,
|
|
10,
|
|
10)
|
|
{
|
|
m_order = order;
|
|
}
|
|
|
|
public override void ValidateDiagnosticInfo(RequestHeader requestHeader)
|
|
{
|
|
m_order.Add("diagnostics");
|
|
base.ValidateDiagnosticInfo(requestHeader);
|
|
}
|
|
|
|
private readonly List<string> m_order;
|
|
}
|
|
}
|
|
}
|