mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-16 21:02:51 +08:00
Server: confirm unchanged isolation classifications at dispatch without the full lookup (#4787)
Roadmap item R3 of the o6 benchmark gap analysis (master `5b03e9afa` profile). Each request was revalidated twice by the resource-isolation provider: at dequeue, and again before execution. Each revalidation repeated: - the owner table lookup (a `ConditionalWeakTable`); - the session binding lookup (token and session dictionaries, the session's expiry clock); - the classifier; - the owner key checks. Now the classification also returns the evidence it was derived from. The queue confirms that evidence at dispatch and runs the full revalidation only when it no longer holds. No wire, configuration or public API change. Every rejection status code, and the order of the checks relative to cancellation and session close, is unchanged: the confirmation only ever short-circuits to Good, and the full revalidation decides every other outcome. ## Results Paired A/B against master `af601ebca` (= `5b03e9afa` + #4778). The builds alternate per round, with the order rotated each round, on the same machine. The table shows medians of 5 rounds, with the throughput range of the rounds in brackets. The machine was shared with other work; 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 (`--server-affinity 0xF`, `DOTNET_PROCESSOR_COUNT=4`). - **The other rows** use the harness's .NET client, 20 sessions unless noted. | Scenario | req/s | Server CPU µs/req | Alloc B/req | |---|---|---|---| | o6read 10×100, 4 cores | 130,300 → 135,400 (+4 %) [129–135k → 132–140k] | 30.3 → 28.6 (**−6 %**) | 4,706 → 4,709 | | o6read 10×100 encrypted, 4 cores | 116,300 → 122,500 (**+5 %**) [113–119k → 118–126k] | 33.4 → 31.5 (**−6 %**) | 4,747 → 4,756 | | o6read 10×100, 24 cores | 344,500 → 347,400 (+1 %) [328–350k → 331–362k] | 53.6 → 52.2 (−3 %) | 4,710 → 4,716 | | o6read 10×1000, 24 cores | 310,800 → 307,300 (−1 %) [297–332k → 307–323k] | 55.6 → 57.5 (+4 %, noise) | 4,719 → 4,730 | | read --nodes 1, 20 sessions | 189,500 → 198,900 (+5 %) | 49.7 → 48.2 (−3 %) | 4,624 → 4,623 | | read --nodes 1, 200 sessions | 174,500 → 179,100 (+3 %) | 50.2 → 44.7 (**−11 %**) | 4,510 → 4,521 | | browse | 36,490 → 35,750 (−2 %) | 179 → 175 (−3 %) | 39,004 → 39,016 | | sub (5 × 100 items, 100 ms) | 94,020 → 93,180 notif/s | 8.2 → 9.2 (rounds 4.5–12.7) | 145 → 145 | **Where it helps:** CPU per request falls by 6 % on 4 cores, plain and encrypted, and by 3–11 % in the read scenarios with the .NET client. **Where it does not:** on 24 cores, CPU per request is within the spread of the rounds. There the cost is dominated by workers of one channel waiting for each other (the request hop and the send-order lock), which this PR does not change. ### Profile cpu trace, 24 cores, 10×100, quiet runs at 433–439k req/s. The trace was taken with this PR and #4788 (R7) together; the frames below belong to this PR only. | Frame | master | this PR | |---|---|---| | `FairRequestQueue.GetRevalidationStatus`, inclusive | 15.4 % | 14.0 % | | of which the provider and session lookups | 15.4 % | 3.9 % (`IsAdmissionCurrent`, mostly the session's expiry clock) | | `SessionManager.TryGetSessionContext`, inclusive | 14.6 % | 0.2 % (admission only) | The lookups are gone, but the dequeue-time check still accounts for about 10 % of the samples as its own time. That is the first touch of the request (entry, request object, channel context) on the worker thread, right after the receive thread wrote it on another core. On master the same cache misses showed up inside `TryGetSessionContext`, which is why the profile share (16.5 %) overstated what this change could save. Removing the request hop (separate work) is what removes those misses. ## Changes ### Evidence with the classification - **Internal interface.** `DefaultServerResourceIsolationProvider` implements a new internal interface, `IResourceIsolationAdmissionEvidence`. Its `Classify` also returns the evidence: the issued owner identity, which holds the channel snapshot and the session binding. - **Kept on the entry.** `FairRequestQueue` keeps the evidence, an internal `ResourceIsolationAdmissionEvidence` record that only the issuing provider interprets, on the queue entry. - **Confirmation.** Before the full revalidation, the queue asks the provider whether the evidence still holds. This applies to both checks, at dequeue and before execution. The provider: - compares the channel snapshot with the request's channel context, as before, so mutated channel evidence is still detected; - asks the binding's liveness check. Only a confirmed Good is short-circuited. - **No evidence, so every revalidation runs in full:** - a classifier is configured, because it may map the same inputs differently at any time; - the request has a token but no binding, because an activation may bind it while the request waits; - the binding comes from another binding provider, which has no liveness check. ### Binding liveness (`SessionManager`) A `SessionBindingContext` issued by `SessionManager` carries an internal liveness check. It runs the checks of `TryGetSessionContext` that can change while the binding and the request's channel context stay the same, without the token and session dictionary lookups: - **The dispatch binding is still this binding.** A removal, a new activation, an identity change, a transfer and an activation in commit all replace it. A commit always creates a new instance. - **The session's channel certificate was not replaced.** `SetRestoredSessionTransferSecurityState` can replace it without a new binding, so the certificate has a version. - **The manager is not shutting down.** Shutdown empties the token index without replacing the bindings. - **The session itself** is activated, not closing or claimed, not expired (the clock-based `HasExpired`, including expiry before the monitor closes the session), and still valid for the channel. These checks run in the same order as in `TryGetSessionContext`. A replaced binding releases its session, so owners that still reference the binding do not keep a closed session alive. ## Testing - **`RuntimeResourceIsolationTests` (new):** - the evidence confirms only an unchanged binding and channel, and a mutated certificate ends it without consulting the session; - it is withheld with a classifier, for a binding without liveness, and for a token without a binding; - a disposed provider throws; - the check allocates nothing. - **`SessionBindingTests` (new):** - the liveness check agrees with a fresh `TryGetSessionContext` through re-activation, transfer and close, an identity change, expiry before the monitor closes the session, an activation in commit, a session that starts closing, and shutdown; - replacing the transfer-security certificate with `SetRestoredSessionTransferSecurityState` keeps the binding but ends the liveness check (fails without the certificate version); - a race of 1000 transfers and a close against 4 readers never confirms a replaced binding, or any binding after the close. - **`FairRequestQueueTests` (new):** - current evidence replaces both full revalidations; - stale evidence at dequeue, or before execution, falls back to the full revalidation and its status (`BadSessionIdInvalid`, `BadServerTooBusy`), and the request does not run. - **Local, net10.0**, with both PRs applied, all 0 failed: - Opc.Ua.Core.Tests (Stack.Server, Stack.Tcp, Stack.Transport): 2753 passed; - Opc.Ua.Server.Tests: 8750 passed; - Opc.Ua.Sessions.Tests: 857 passed (379 skipped as on master); - Opc.Ua.Redundancy.Server.Tests: 884 passed; Opc.Ua.Redundancy.Client.Tests: 134 passed; - Opc.Ua.Stress.Tests Contract + Integration: 45 passed. net48 builds. `dotnet format whitespace --verify-no-changes` passes on the changed files. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1011,6 +1011,91 @@ namespace Opc.Ua.Core.Tests.Stack.Server
|
||||
Assert.That(provider.TotalUsed, Is.Zero);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Current admission evidence confirms the classification at dequeue and again before
|
||||
/// execution without the full revalidation.
|
||||
/// </summary>
|
||||
[TestCase(false)]
|
||||
[TestCase(true)]
|
||||
public async Task CurrentAdmissionEvidenceReplacesFullRevalidationAsync(bool useFairScheduling)
|
||||
{
|
||||
var provider = new TestProvider { UseFairScheduling = useFairScheduling, IssueEvidence = true };
|
||||
NodeId token = provider.AddOwner("A");
|
||||
using var server = new QueueServer(provider, workers: 1);
|
||||
using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
var request = new TestRequest(token);
|
||||
server.Enqueue(request);
|
||||
await request.Started.Task.WaitAsync(deadline.Token).ConfigureAwait(false);
|
||||
request.Release();
|
||||
await request.Completed.Task.WaitAsync(deadline.Token).ConfigureAwait(false);
|
||||
|
||||
Assert.That(request.Status, Is.EqualTo(StatusCodes.Good));
|
||||
Assert.That(provider.EvidenceChecks, Is.EqualTo(2), "dequeue and execution");
|
||||
Assert.That(provider.FullRevalidations, Is.Zero);
|
||||
await server.FinishAsync(deadline.Token).ConfigureAwait(false);
|
||||
Assert.That(provider.TotalUsed, Is.Zero);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evidence that no longer holds at dequeue falls back to the full revalidation, which
|
||||
/// decides the status.
|
||||
/// </summary>
|
||||
[TestCase(false)]
|
||||
[TestCase(true)]
|
||||
public async Task StaleAdmissionEvidenceAtDequeueUsesFullRevalidationStatusAsync(bool useFairScheduling)
|
||||
{
|
||||
var provider = new TestProvider { UseFairScheduling = useFairScheduling, IssueEvidence = true };
|
||||
NodeId token = provider.AddOwner("A");
|
||||
provider.EvidenceCheck = _ =>
|
||||
{
|
||||
provider.RemoveBinding(token);
|
||||
return false;
|
||||
};
|
||||
using var server = new QueueServer(provider, workers: 1);
|
||||
using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
var request = new TestRequest(token);
|
||||
server.Enqueue(request);
|
||||
await request.Completed.Task.WaitAsync(deadline.Token).ConfigureAwait(false);
|
||||
|
||||
Assert.That(request.Status, Is.EqualTo(StatusCodes.BadSessionIdInvalid));
|
||||
Assert.That(request.Started.Task.IsCompleted, Is.False);
|
||||
Assert.That(provider.FullRevalidations, Is.EqualTo(1));
|
||||
await server.FinishAsync(deadline.Token).ConfigureAwait(false);
|
||||
Assert.That(provider.TotalUsed, Is.Zero);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A change between dequeue and execution is still caught by the check before execution.
|
||||
/// </summary>
|
||||
[TestCase(false)]
|
||||
[TestCase(true)]
|
||||
public async Task StaleAdmissionEvidenceBeforeExecutionUsesFullRevalidationStatusAsync(bool useFairScheduling)
|
||||
{
|
||||
var provider = new TestProvider { UseFairScheduling = useFairScheduling, IssueEvidence = true };
|
||||
NodeId token = provider.AddOwner("A");
|
||||
provider.EvidenceCheck = check =>
|
||||
{
|
||||
if (check == 1)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
provider.ReplaceBinding(token, "channel");
|
||||
return false;
|
||||
};
|
||||
using var server = new QueueServer(provider, workers: 1);
|
||||
using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
var request = new TestRequest(token);
|
||||
server.Enqueue(request);
|
||||
await request.Completed.Task.WaitAsync(deadline.Token).ConfigureAwait(false);
|
||||
|
||||
Assert.That(request.Status, Is.EqualTo(StatusCodes.BadServerTooBusy));
|
||||
Assert.That(request.Started.Task.IsCompleted, Is.False);
|
||||
Assert.That(provider.EvidenceChecks, Is.EqualTo(2));
|
||||
Assert.That(provider.FullRevalidations, Is.EqualTo(1));
|
||||
await server.FinishAsync(deadline.Token).ConfigureAwait(false);
|
||||
Assert.That(provider.TotalUsed, Is.Zero);
|
||||
}
|
||||
|
||||
[TestCase(false)]
|
||||
[TestCase(true)]
|
||||
public async Task ProviderFailureFaultsOnlyThatRequestAndKeepsTheWorkerAliveAsync(bool useFairScheduling)
|
||||
@@ -2035,7 +2120,8 @@ namespace Opc.Ua.Core.Tests.Stack.Server
|
||||
/// <summary>
|
||||
/// Provides deterministic owner limits and admission-race hooks for queue regressions.
|
||||
/// </summary>
|
||||
private sealed class TestProvider : IServerResourceIsolationProvider, IResourceIsolationRevalidationProvider
|
||||
private sealed class TestProvider : IServerResourceIsolationProvider, IResourceIsolationRevalidationProvider,
|
||||
IResourceIsolationAdmissionEvidence
|
||||
{
|
||||
public TestProvider(
|
||||
long executionCapacity = 100,
|
||||
@@ -2167,6 +2253,40 @@ namespace Opc.Ua.Core.Tests.Stack.Server
|
||||
owner, Classify(channelContext, authenticationToken, sessionEstablishment, controlRequest));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns admission evidence with each classification; off by default.
|
||||
/// </summary>
|
||||
public bool IssueEvidence { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Decides each evidence check; the argument counts the checks from one.
|
||||
/// </summary>
|
||||
public Func<int, bool> EvidenceCheck { get; set; } = _ => true;
|
||||
|
||||
public int EvidenceChecks => Volatile.Read(ref m_evidenceChecks);
|
||||
|
||||
public int FullRevalidations => Volatile.Read(ref m_fullRevalidations);
|
||||
|
||||
ResourceIsolationOwner IResourceIsolationAdmissionEvidence.Classify(
|
||||
SecureChannelContext channelContext,
|
||||
NodeId authenticationToken,
|
||||
bool sessionEstablishment,
|
||||
bool controlRequest,
|
||||
out ResourceIsolationAdmissionEvidence? evidence)
|
||||
{
|
||||
ResourceIsolationOwner owner = Classify(
|
||||
channelContext, authenticationToken, sessionEstablishment, controlRequest);
|
||||
evidence = IssueEvidence ? new TestEvidence(owner) : null;
|
||||
return owner;
|
||||
}
|
||||
|
||||
bool IResourceIsolationAdmissionEvidence.IsAdmissionCurrent(
|
||||
ResourceIsolationAdmissionEvidence evidence,
|
||||
SecureChannelContext channelContext)
|
||||
{
|
||||
return EvidenceCheck(Interlocked.Increment(ref m_evidenceChecks));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Distinguishes same-channel replacement from a missing or transferred binding.
|
||||
/// </summary>
|
||||
@@ -2177,6 +2297,7 @@ namespace Opc.Ua.Core.Tests.Stack.Server
|
||||
bool sessionEstablishment = false,
|
||||
bool controlRequest = false)
|
||||
{
|
||||
Interlocked.Increment(ref m_fullRevalidations);
|
||||
if (IsCurrent(owner, channelContext, authenticationToken, sessionEstablishment, controlRequest))
|
||||
{
|
||||
return StatusCodes.Good;
|
||||
@@ -2306,6 +2427,13 @@ namespace Opc.Ua.Core.Tests.Stack.Server
|
||||
private readonly Dictionary<NodeId, (ResourceIsolationOwner Owner, string Channel)> m_bindings = [];
|
||||
private readonly Dictionary<(string Owner, ResourceIsolationStage Stage), long> m_ownerUsed = [];
|
||||
private long m_sharedCostUsed;
|
||||
/// <summary>
|
||||
/// The evidence the provider issues: the owner it classified.
|
||||
/// </summary>
|
||||
private sealed record TestEvidence(ResourceIsolationOwner Owner) : ResourceIsolationAdmissionEvidence;
|
||||
|
||||
private int m_evidenceChecks;
|
||||
private int m_fullRevalidations;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -355,6 +355,126 @@ namespace Opc.Ua.Server.Tests
|
||||
Is.EqualTo(StatusCodes.BadSessionIdInvalid));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AdmissionEvidenceConfirmsOnlyAnUnchangedBindingAndChannel()
|
||||
{
|
||||
var bindings = new TestBindings();
|
||||
var token = new NodeId(1);
|
||||
var liveness = new TestLiveness();
|
||||
SessionBindingContext binding = Binding(1, "user-a");
|
||||
binding.Liveness = liveness;
|
||||
bindings.Bindings.Add(token, binding);
|
||||
using DefaultServerResourceIsolationProvider provider = CreateProvider(Options(), bindings: bindings);
|
||||
var evidenceProvider = (IResourceIsolationAdmissionEvidence)provider;
|
||||
byte[] certificate = [1, 2, 3];
|
||||
SecureChannelContext channel = Channel("channel", certificate);
|
||||
|
||||
ResourceIsolationOwner owner = evidenceProvider.Classify(channel, token, false, false, out ResourceIsolationAdmissionEvidence? evidence);
|
||||
Assert.That(owner, Is.SameAs(provider.Classify(channel, token)));
|
||||
Assert.That(evidence, Is.Not.Null);
|
||||
Assert.That(evidenceProvider.IsAdmissionCurrent(evidence!, channel), Is.True);
|
||||
Assert.That(liveness.Checks, Is.EqualTo(1));
|
||||
|
||||
// Every trigger the liveness check reports (close, expiry, re-activation, transfer,
|
||||
// shutdown) ends the evidence; the full revalidation then decides the status.
|
||||
liveness.Current = false;
|
||||
Assert.That(evidenceProvider.IsAdmissionCurrent(evidence!, channel), Is.False);
|
||||
bindings.Bindings.Clear();
|
||||
Assert.That(provider.GetRevalidationStatus(owner, channel, token),
|
||||
Is.EqualTo(StatusCodes.BadSessionIdInvalid));
|
||||
|
||||
// Changed channel evidence ends it without consulting the session.
|
||||
liveness.Current = true;
|
||||
bindings.Bindings.Add(token, binding);
|
||||
Assert.That(evidenceProvider.IsAdmissionCurrent(evidence!, channel), Is.True);
|
||||
int checks = liveness.Checks;
|
||||
certificate[0] = 9;
|
||||
Assert.That(evidenceProvider.IsAdmissionCurrent(evidence!, channel), Is.False);
|
||||
Assert.That(liveness.Checks, Is.EqualTo(checks));
|
||||
Assert.That(provider.GetRevalidationStatus(owner, channel, token),
|
||||
Is.EqualTo(StatusCodes.BadSecureChannelIdInvalid));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AdmissionEvidenceWithoutSessionTokenDependsOnTheChannelOnly()
|
||||
{
|
||||
using DefaultServerResourceIsolationProvider provider = CreateProvider(Options());
|
||||
var evidenceProvider = (IResourceIsolationAdmissionEvidence)provider;
|
||||
byte[] certificate = [1, 2, 3];
|
||||
SecureChannelContext channel = Channel("channel", certificate);
|
||||
|
||||
_ = evidenceProvider.Classify(channel, default, true, false, out ResourceIsolationAdmissionEvidence? evidence);
|
||||
Assert.That(evidence, Is.Not.Null);
|
||||
Assert.That(evidenceProvider.IsAdmissionCurrent(evidence!, channel), Is.True);
|
||||
certificate[0] = 9;
|
||||
Assert.That(evidenceProvider.IsAdmissionCurrent(evidence!, channel), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AdmissionEvidenceIsWithheldWhenItCannotBeConfirmedCheaply()
|
||||
{
|
||||
var bindings = new TestBindings();
|
||||
bindings.Bindings.Add(new NodeId(1), Binding(1, "user-a"));
|
||||
using DefaultServerResourceIsolationProvider provider = CreateProvider(Options(), bindings: bindings);
|
||||
var evidenceProvider = (IResourceIsolationAdmissionEvidence)provider;
|
||||
SecureChannelContext channel = Channel("channel", [1, 2, 3]);
|
||||
|
||||
// A binding issued without a liveness check.
|
||||
_ = evidenceProvider.Classify(channel, new NodeId(1), false, false, out ResourceIsolationAdmissionEvidence? evidence);
|
||||
Assert.That(evidence, Is.Null);
|
||||
// A token without a binding: an activation can bind it while the request waits.
|
||||
_ = evidenceProvider.Classify(channel, new NodeId(2), false, false, out evidence);
|
||||
Assert.That(evidence, Is.Null);
|
||||
|
||||
// A classifier may map the same inputs differently at any time.
|
||||
var classifier = new Mock<IResourceIsolationClassifier>();
|
||||
using DefaultServerResourceIsolationProvider classified = CreateProvider(Options(), classifier.Object);
|
||||
_ = ((IResourceIsolationAdmissionEvidence)classified).Classify(
|
||||
channel, default, false, false, out evidence);
|
||||
Assert.That(evidence, Is.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AdmissionEvidenceIsNotConfirmedByADisposedProvider()
|
||||
{
|
||||
DefaultServerResourceIsolationProvider provider = CreateProvider(Options());
|
||||
var evidenceProvider = (IResourceIsolationAdmissionEvidence)provider;
|
||||
SecureChannelContext channel = Channel("channel", [1, 2, 3]);
|
||||
_ = evidenceProvider.Classify(channel, default, false, false, out ResourceIsolationAdmissionEvidence? evidence);
|
||||
provider.Dispose();
|
||||
|
||||
Assert.That(() => evidenceProvider.IsAdmissionCurrent(evidence!, channel),
|
||||
Throws.TypeOf<ObjectDisposedException>());
|
||||
}
|
||||
|
||||
#if NET8_0_OR_GREATER
|
||||
[Test]
|
||||
public void AdmissionEvidenceCheckDoesNotAllocate()
|
||||
{
|
||||
var bindings = new TestBindings();
|
||||
var token = new NodeId(1);
|
||||
SessionBindingContext binding = Binding(1, "user-a");
|
||||
binding.Liveness = new TestLiveness();
|
||||
bindings.Bindings.Add(token, binding);
|
||||
using DefaultServerResourceIsolationProvider provider = CreateProvider(Options(), bindings: bindings);
|
||||
var evidenceProvider = (IResourceIsolationAdmissionEvidence)provider;
|
||||
SecureChannelContext channel = Channel("channel", new byte[1500]);
|
||||
_ = evidenceProvider.Classify(channel, token, false, false, out ResourceIsolationAdmissionEvidence? evidence);
|
||||
_ = evidenceProvider.IsAdmissionCurrent(evidence!, channel);
|
||||
|
||||
long before = GC.GetAllocatedBytesForCurrentThread();
|
||||
bool current = true;
|
||||
for (int ii = 0; ii < 1000; ii++)
|
||||
{
|
||||
current &= evidenceProvider.IsAdmissionCurrent(evidence!, channel);
|
||||
}
|
||||
long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
|
||||
|
||||
Assert.That(current, Is.True);
|
||||
Assert.That(allocated, Is.Zero);
|
||||
}
|
||||
#endif
|
||||
|
||||
[Test]
|
||||
public void SessionDerivedKeysAreCachedPerActivationNotChannelOrSequence()
|
||||
{
|
||||
@@ -1924,6 +2044,19 @@ namespace Opc.Ua.Server.Tests
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestLiveness : ISessionBindingLiveness
|
||||
{
|
||||
public bool Current { get; set; } = true;
|
||||
|
||||
public int Checks { get; private set; }
|
||||
|
||||
public bool IsCurrent(SecureChannelContext channelContext)
|
||||
{
|
||||
Checks++;
|
||||
return Current;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestClassifier : IResourceIsolationClassifier
|
||||
{
|
||||
public bool TryClassifyIngress(IPEndPoint? remoteEndpoint, out ResourceIsolationIdentity identity)
|
||||
|
||||
@@ -468,6 +468,223 @@ namespace Opc.Ua.Server.Tests
|
||||
Assert.That(m_manager.HasSession("b"), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task BindingLivenessFollowsLookupThroughReactivationTransferAndCloseAsync()
|
||||
{
|
||||
OperationContext oldChannel = CreateContext("old");
|
||||
OperationContext newChannel = CreateContext("new");
|
||||
CreateSessionResult created = await CreateAsync(oldChannel).ConfigureAwait(false);
|
||||
await ActivateAsync(created, oldChannel).ConfigureAwait(false);
|
||||
SessionBindingContext first = GetBinding(created, oldChannel);
|
||||
AssertLive(first, created, oldChannel, expected: true);
|
||||
|
||||
await ActivateAsync(created, oldChannel).ConfigureAwait(false);
|
||||
SessionBindingContext second = GetBinding(created, oldChannel);
|
||||
AssertLive(first, created, oldChannel, expected: false);
|
||||
AssertLive(second, created, oldChannel, expected: true);
|
||||
|
||||
await ActivateAsync(created, newChannel).ConfigureAwait(false);
|
||||
SessionBindingContext transferred = GetBinding(created, newChannel);
|
||||
AssertLive(second, created, oldChannel, expected: false);
|
||||
AssertLive(transferred, created, newChannel, expected: true);
|
||||
|
||||
await m_manager.CloseSessionAsync(created.SessionId).ConfigureAwait(false);
|
||||
AssertLive(transferred, created, newChannel, expected: false);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task BindingLivenessEndsWhenIdentityChangesAsync()
|
||||
{
|
||||
OperationContext channel = CreateContext("one");
|
||||
CreateSessionResult created = await CreateAsync(channel).ConfigureAwait(false);
|
||||
await ActivateUserAsync("alice").ConfigureAwait(false);
|
||||
SessionBindingContext alice = GetBinding(created, channel);
|
||||
AssertLive(alice, created, channel, expected: true);
|
||||
await ActivateUserAsync("bob").ConfigureAwait(false);
|
||||
SessionBindingContext bob = GetBinding(created, channel);
|
||||
|
||||
AssertLive(alice, created, channel, expected: false);
|
||||
AssertLive(bob, created, channel, expected: true);
|
||||
await m_manager.CloseSessionAsync(created.SessionId).ConfigureAwait(false);
|
||||
|
||||
async Task ActivateUserAsync(string userName)
|
||||
{
|
||||
await m_manager.ActivateSessionAsync(
|
||||
channel, created.AuthenticationToken, null,
|
||||
new ExtensionObject(new UserNameIdentityToken
|
||||
{
|
||||
PolicyId = "username",
|
||||
UserName = userName,
|
||||
Password = ByteString.From([1])
|
||||
}),
|
||||
null, []).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task BindingLivenessEndsWhenSessionExpiresBeforeTheMonitorClosesItAsync()
|
||||
{
|
||||
OperationContext channel = CreateContext("one");
|
||||
CreateSessionResult created = await CreateAsync(channel).ConfigureAwait(false);
|
||||
await ActivateAsync(created, channel).ConfigureAwait(false);
|
||||
SessionBindingContext binding = GetBinding(created, channel);
|
||||
m_clock.Advance(TimeSpan.FromMilliseconds(59_000));
|
||||
AssertLive(binding, created, channel, expected: true);
|
||||
m_clock.Advance(TimeSpan.FromMilliseconds(1_001));
|
||||
|
||||
AssertLive(binding, created, channel, expected: false);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task BindingLivenessEndsWhileActivationCommitsAsync()
|
||||
{
|
||||
OperationContext channel = CreateContext("one");
|
||||
CreateSessionResult created = await CreateAsync(channel).ConfigureAwait(false);
|
||||
await ActivateAsync(created, channel).ConfigureAwait(false);
|
||||
SessionBindingContext original = GetBinding(created, channel);
|
||||
bool? liveDuringCommit = null;
|
||||
m_manager.OnNextSessionActivated = () =>
|
||||
liveDuringCommit = original.Liveness!.IsCurrent(channel.ChannelContext!);
|
||||
|
||||
await ActivateAsync(created, channel).ConfigureAwait(false);
|
||||
|
||||
Assert.That(liveDuringCommit, Is.False);
|
||||
AssertLive(original, created, channel, expected: false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replacing the certificate requests are validated against keeps the binding, so the
|
||||
/// certificate version is the only signal that ends the liveness check. On a None
|
||||
/// channel the lookup ignores the certificate and still returns the binding: evidence
|
||||
/// issued before the change is then not confirmed, and the full check decides.
|
||||
/// (Activating on a secured channel needs a client signature this fixture cannot make.)
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task BindingLivenessEndsWhenTransferSecurityCertificateChangesAsync()
|
||||
{
|
||||
OperationContext channel = CreateContext("one", certificate: [1, 2, 3]);
|
||||
CreateSessionResult created = await CreateAsync(channel).ConfigureAwait(false);
|
||||
await ActivateAsync(created, channel).ConfigureAwait(false);
|
||||
SessionBindingContext binding = GetBinding(created, channel);
|
||||
AssertLive(binding, created, channel, expected: true);
|
||||
|
||||
m_manager.PublicSetRestoredSessionTransferSecurityState(
|
||||
created.Session, ByteString.From([4, 5, 6]), SecurityPolicies.None,
|
||||
MessageSecurityMode.None, UserTokenType.Anonymous, null);
|
||||
|
||||
Assert.That(binding.Liveness!.IsCurrent(channel.ChannelContext!), Is.False);
|
||||
Assert.That(GetBinding(created, channel), Is.SameAs(binding),
|
||||
"The binding stays committed; only the evidence issued before the change ends.");
|
||||
await m_manager.CloseSessionAsync(created.SessionId).ConfigureAwait(false);
|
||||
}
|
||||
[Test]
|
||||
public async Task BindingLivenessEndsWhenSessionStartsClosingAsync()
|
||||
{
|
||||
OperationContext channel = CreateContext("one");
|
||||
CreateSessionResult created = await CreateAsync(channel).ConfigureAwait(false);
|
||||
await ActivateAsync(created, channel).ConfigureAwait(false);
|
||||
SessionBindingContext binding = GetBinding(created, channel);
|
||||
Assert.That(((Server.Session)created.Session).MarkClosing(), Is.True);
|
||||
|
||||
AssertLive(binding, created, channel, expected: false);
|
||||
await m_manager.CloseSessionAsync(created.SessionId).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task BindingLivenessEndsAtShutdownAsync()
|
||||
{
|
||||
OperationContext channel = CreateContext("one");
|
||||
CreateSessionResult created = await CreateAsync(channel).ConfigureAwait(false);
|
||||
await ActivateAsync(created, channel).ConfigureAwait(false);
|
||||
SessionBindingContext binding = GetBinding(created, channel);
|
||||
|
||||
await m_manager.ShutdownAsync().ConfigureAwait(false);
|
||||
|
||||
AssertLive(binding, created, channel, expected: false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A binding the liveness check confirms must still be the committed one: never one
|
||||
/// older than an activation that already completed, and none once the close completed.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task BindingLivenessRacingTransfersAndCloseNeverConfirmsReplacedBindingAsync()
|
||||
{
|
||||
const int kTransfers = 1000;
|
||||
const int kReaders = 4;
|
||||
OperationContext[] channels = [CreateContext("a"), CreateContext("b")];
|
||||
CreateSessionResult created = await CreateAsync(channels[0]).ConfigureAwait(false);
|
||||
await ActivateAsync(created, channels[0]).ConfigureAwait(false);
|
||||
long completed = 1;
|
||||
int closed = 0;
|
||||
int checks = 0;
|
||||
int confirmed = 0;
|
||||
var errors = new ConcurrentQueue<string>();
|
||||
using var stop = new CancellationTokenSource();
|
||||
|
||||
var readers = new Task[kReaders];
|
||||
for (int r = 0; r < kReaders; r++)
|
||||
{
|
||||
int reader = r;
|
||||
readers[r] = Task.Run(() =>
|
||||
{
|
||||
int attempt = 0;
|
||||
while (!stop.IsCancellationRequested)
|
||||
{
|
||||
OperationContext channel = channels[(reader + attempt++) & 1];
|
||||
if (!m_manager.TryGetSessionContext(
|
||||
created.AuthenticationToken, channel.ChannelContext!, out SessionBindingContext? binding))
|
||||
{
|
||||
Interlocked.Increment(ref checks);
|
||||
continue;
|
||||
}
|
||||
// keep checking the binding the request would have been classified with
|
||||
for (int ii = 0; ii < 16; ii++)
|
||||
{
|
||||
bool closedBefore = Volatile.Read(ref closed) != 0;
|
||||
long floor = Volatile.Read(ref completed);
|
||||
bool live = binding.Liveness!.IsCurrent(channel.ChannelContext!);
|
||||
Interlocked.Increment(ref checks);
|
||||
if (!live)
|
||||
{
|
||||
break;
|
||||
}
|
||||
Interlocked.Increment(ref confirmed);
|
||||
if (closedBefore || binding.ActivationSequence < floor)
|
||||
{
|
||||
errors.Enqueue(
|
||||
$"closed={closedBefore} seq={binding.ActivationSequence} floor={floor}");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
for (int i = 1; i <= kTransfers; i++)
|
||||
{
|
||||
await ActivateAsync(created, channels[i & 1]).ConfigureAwait(false);
|
||||
Volatile.Write(ref completed, i + 1);
|
||||
}
|
||||
await m_manager.CloseSessionAsync(created.SessionId).ConfigureAwait(false);
|
||||
Volatile.Write(ref closed, 1);
|
||||
int target = Volatile.Read(ref checks) + (kReaders * 1000);
|
||||
while (Volatile.Read(ref checks) < target && errors.IsEmpty)
|
||||
{
|
||||
await Task.Yield();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
stop.Cancel();
|
||||
}
|
||||
await Task.WhenAll(readers).ConfigureAwait(false);
|
||||
|
||||
Assert.That(errors, Is.Empty, string.Join(Environment.NewLine, errors));
|
||||
Assert.That(confirmed, Is.GreaterThan(0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ExpiredSessionLosesClassificationAndTimeoutRemovesMembershipAsync()
|
||||
{
|
||||
@@ -733,6 +950,24 @@ namespace Opc.Ua.Server.Tests
|
||||
return binding!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks the liveness of a binding that was returned for the channel, and that it
|
||||
/// agrees with a fresh lookup.
|
||||
/// </summary>
|
||||
private void AssertLive(
|
||||
SessionBindingContext binding,
|
||||
CreateSessionResult created,
|
||||
OperationContext channel,
|
||||
bool expected)
|
||||
{
|
||||
Assert.That(binding.Liveness, Is.Not.Null);
|
||||
Assert.That(binding.Liveness!.IsCurrent(channel.ChannelContext!), Is.EqualTo(expected));
|
||||
bool current = m_manager.TryGetSessionContext(
|
||||
created.AuthenticationToken, channel.ChannelContext!, out SessionBindingContext? found) &&
|
||||
ReferenceEquals(found, binding);
|
||||
Assert.That(current, Is.EqualTo(expected), "The lookup disagrees with the liveness check.");
|
||||
}
|
||||
|
||||
private void AssertNoBinding(NodeId token, OperationContext channel)
|
||||
{
|
||||
Assert.That(m_manager.TryGetSessionContext(
|
||||
@@ -900,6 +1135,19 @@ namespace Opc.Ua.Server.Tests
|
||||
ReevaluateIdentityIfStale(session, channelContext);
|
||||
}
|
||||
|
||||
public void PublicSetRestoredSessionTransferSecurityState(
|
||||
ISession session,
|
||||
ByteString originalClientChannelCertificate,
|
||||
string securityPolicyUri,
|
||||
MessageSecurityMode securityMode,
|
||||
UserTokenType clientUserTokenType,
|
||||
string? clientUserId)
|
||||
{
|
||||
SetRestoredSessionTransferSecurityState(
|
||||
session, originalClientChannelCertificate, securityPolicyUri, securityMode,
|
||||
clientUserTokenType, clientUserId);
|
||||
}
|
||||
|
||||
protected override IUserIdentity AddMandatoryRoles(
|
||||
ISession session, OperationContext context, IUserIdentity effectiveIdentity)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user