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:
romanett
2026-10-15 12:52:48 +02:00
committed by GitHub
parent d764161a53
commit 19a97da52e
8 changed files with 753 additions and 23 deletions
@@ -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)
{