7 Commits
Author SHA1 Message Date
romanettandClaude Opus 5.5 19a97da52e 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>
2026-10-15 12:52:48 +02:00
Marc Schier 7c965fd636 Capture metadata images and generation-bound session clients (#4752)
# Description

- Add isolated NodeState reference images, TypeTable graph snapshots,
EncodeableFactory registration forks/views, StringTable mutation
versions/private copies, and ServiceMessageContext mapping-holder
generation tracking. Reference images are lazy for empty nodes;
selected-image clones own independent storage and publication ownership.
- Add the complete generic NodeBrowser continuation-dependency
capability. This does not implement server browse-history retention,
NodeManager read images, or server batch/readiness admission.
- Add optional client binding capture for SessionClient, native Session
and ManagedSession, with managed-lease/native-channel generation
composition. Bindings capture authenticated-session identity, mapping
holders/versions and owned endpoint/policy/configuration observations.
Replacement, reconnect, mapping ABA and disposal permanently invalidate
stale bindings; disposing a binding does not dispose its owner.
- Preserve cancellation and master pooled-response ownership: a rejected
pooled response is released exactly once; an accepted response remains
caller-owned. Keep native transport submission under its replacement
gate and completion outside the gate, without routing ordinary unbound
requests through binding machinery.
- Rename the existing server lookup interface and all actual consumers
with minimal hunks; add focused fixtures, generated FileTypeClient usage
guidance, and package/transport documentation.

**Breaking preview type identity:** the existing 2.0-preview server
lookup `Opc.Ua.ISessionBindingProvider` becomes
`Opc.Ua.IServerSessionBindingProvider`. Server lookup consumers must
update their referenced type and rebuild. This is neither
source-compatible nor binary-additive for that preview type. The new
client capability is optional and adds no mandatory members to existing
ISession/ISessionClient/ITransportChannel contracts. This
preview-to-preview rename is documented in SessionBindings/Transports,
not in the 1.5-to-2.0 MigrationGuide.

**Ownership boundary:** no RequestLifetime changes,
certificate-manager/channel-entry adoption, BaseVariableState changes,
Method-result feature extraction, NodeManager batch/readiness or
browse-history retention, session restoration, registry federation, WoT
conversion, or WoT runtime/planners/events.
DependencyInjection/ResourceIsolation/index changes are lookup/link
closure only. SessionManager's PR hunk changes only its implemented
lookup-interface name; newer master public diagnostic masking is not
replaced.

## Related Issues

_Reference all GitHub issues this PR addresses. If there is no issue
yet, open one and link it here._

_If this is a relatively large or complex change, a design must have
been discussed in the related tracking issue and signed off (which
becomes the Architectural Decision Record (ADR))._

- Part of #4511: user-approved combined groups 5+6 Wave A extraction.
This PR does not close the umbrella or publish Wave B.

## Checklist

_Put an `x` in the boxes that apply. You can complete these step by step
after opening the PR._

- [ ] 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.
- [ ] 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.
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [x] I have addressed **all** PR feedback received.

### Review qualification

Review fixes are published at
**`6b0eb1d5c32a439300fbcaff974b9af8b909161b`**, changing eight
already-owned files. All five received review threads were replied to
and resolved after verification. Factory selector installation and
target-selection-plus-commit share the root publication gate, with
consistent root→image release/capture ordering. TypeTable selector
installation and final mutation routing share its existing gate. Native
bound channel validity uses volatile observations; the two owned XML
defects are corrected.

| Requirement | Evidence |
|---|---|
| Selector installation must not hide TypeTable mutations |
`SelectorInstalledDuringNodePreparationReceivesMutation`: old Add
routing failed on both TFMs; corrected routing passes on both within
**148/148** factory/TypeTable controls |
| Selector installation must not race factory commit routing | Static
common-root-gate/order proof plus baseline-GREEN
`BuilderCapturedBeforeOwnershipUsesSelectedFactory`,
`ViewOwnerReleasePreservesRegistrationsAndAllowsReuse`,
`ViewOwnerReleaseCanRetryAfterPendingPublication`,
`PublicationProtectsPendingAndRetiredFactoryImages` |
| Lock-free validity must observe owner transitions | Static
volatile-read reasoning plus native
`BoundRequestCannotDispatchWhileNativeReplacementIsPendingAsync`,
`BoundResponseRejectsInvalidationAfterNativeDispatchAsync`,
`BoundClientCannotRetryAcrossFaultedManagedEntryReplacementAsync`:
**12/12 each TFM**; Core binding/pooled ownership controls **17/17 each
TFM** |
| Correct owned XML documentation | “an unmanaged” and one multiline
`ReferenceUpdate.Next` summary; scoped whitespace/INFO and build
evidence below |

Factory controls are **not** claimed as a deterministic factory RED, and
public transport controls are **not** claimed as a deterministic
weak-memory RED. No private-lock reflection, artificial production seam,
or source-name assertion was added. The TypeTable RED uses the real
`ILocalNode.NodeClass` callback during node preparation, and both
expected RED receipts/TRX were retained.

All accepted review runs use the unchanged admitted SDK wrapper, normal
project references, sole mutex, one node/no shared compiler, finite
resource guards and canonical short-C build outputs. Source hashes were
frozen per command and matched the committed bytes afterward. SDK
ownership was released **before** commit/push/review replies. No
automatic merge was requested.

Scoped whitespace passed across the four affected projects. Review INFO
classification is **0 owned findings / 13 untouched baseline findings
excluded** (whole-file formatter exits 1,1,0,0); no baseline
RCS1140/RCS0055 expansion or suppression. The final normal net10 public
Client/Core/Types trim/AOT-analyzer closure passed **0 warnings / 0
errors**. This is not a NativeAOT publish/run.

<details>
<summary>Accepted review command payloads and results</summary>

These exact argument payloads were executed through the unchanged
guarded SDK wrapper. `$proof` expands to the protected results-directory
below. Successful explicit builds of the same source preceded the
`--no-build` runs.

```powershell
$proof = 'C:\Users\mschier\.copilot\session-state\a3b74048-c52f-4c87-8773-7a46c9ab0087\files\qualification-review-4752'

# Exit 0: 148 passed, 0 failed
dotnet test tests\Opc.Ua.Types.Tests\Opc.Ua.Types.Tests.csproj -f net48 -p:CustomTestTarget=net48 -p:UseArtifactsOutput=true -p:ArtifactsPath=C:\cp56-a3b7-v2 -nr:false --no-restore --verbosity minimal --filter "FullyQualifiedName~EncodeableFactorySnapshotTests|FullyQualifiedName~TypeTableTests" --logger "trx;LogFileName=review-types-green-net48.trx" --results-directory $proof

# Exit 0: 148 passed, 0 failed
dotnet test tests\Opc.Ua.Types.Tests\Opc.Ua.Types.Tests.csproj -f net10.0 --no-restore --no-build --filter "FullyQualifiedName~EncodeableFactorySnapshotTests|FullyQualifiedName~TypeTableTests" --logger "trx;LogFileName=review-types-green-net10.trx" --results-directory $proof -p:CustomTestTarget=net10.0 -p:UseArtifactsOutput=true -p:ArtifactsPath=C:\cp56-a3b7-v2 -nr:false --verbosity minimal

# The following two per-TFM payloads each exited 0:
# Core: 17/17 net48 and 17/17 net10.0; Client: 12/12 net48 and 12/12 net10.0
foreach ($tfm in @('net48', 'net10.0')) {
    dotnet test tests\Opc.Ua.Core.Tests\Opc.Ua.Core.Tests.csproj -f $tfm --no-restore --no-build --filter "FullyQualifiedName~SessionBindingResponseTests|FullyQualifiedName~SessionBindingContextTests" --logger "trx;LogFileName=review-actual-core-green-$tfm.trx" --results-directory $proof -p:CustomTestTarget=$tfm -p:UseArtifactsOutput=true -p:ArtifactsPath=C:\cp56-a3b7-v2 -nr:false --verbosity minimal
    dotnet test tests\Opc.Ua.Client.Tests\Opc.Ua.Client.Tests.csproj -f $tfm --no-restore --no-build --filter "FullyQualifiedName~BoundRequestCannotDispatchWhileNativeReplacementIsPendingAsync|FullyQualifiedName~BoundResponseRejectsInvalidationAfterNativeDispatchAsync|FullyQualifiedName~BoundClientCannotRetryAcrossFaultedManagedEntryReplacementAsync" --logger "trx;LogFileName=review-actual-client-green-$tfm.trx" --results-directory $proof -p:CustomTestTarget=$tfm -p:UseArtifactsOutput=true -p:ArtifactsPath=C:\cp56-a3b7-v2 -nr:false --verbosity minimal
}

# Exit 0: 0 warnings, 0 errors
dotnet build src\Opc.Ua.Client\Opc.Ua.Client.csproj -f net10.0 -p:CustomTestTarget=net10.0 -p:UseArtifactsOutput=true -p:ArtifactsPath=C:\cp56-a3b7-v2 -nr:false --no-restore --verbosity minimal
```

The six expanded GREEN commands, exact results/TRX hashes and two
expected RED commands are retained in
`qualification-review-4752\qualification-summary.json`, SHA256
`C5E71A4D8D37BAC8803F6DF38B6BCE2B70F3D0AFD5DEC8493FAE9DB440DCFB5A`.
Committed/qualified byte identity is in `publication-identity.json`,
SHA256
`CD9515FE96703F8B241F2B7B9B71F73EF0FF2626C2C8BCCE40307971A8F8E809`.
Per-command raw exits, guards, hashes and reports are retained outside
disposable build roots.

For each record in the protected `format-inputs.json` (Types/Core/Client
libraries and Types.Tests), the recorded Project/Include values expand
these exact payloads:

```powershell
# Workspace properties recorded with the formatter receipts:
$env:CustomTestTarget = 'net10.0'
$env:UseArtifactsOutput = 'true'
$env:ArtifactsPath = 'C:\cp56-a3b7-v2'

# All four exit 0
dotnet format whitespace $Project --no-restore --verify-no-changes --verbosity minimal --include $Include

# Exits 1,1,0,0 due only to untouched baseline findings; owned=0, baseline=13
dotnet format $Project --no-restore --severity info --verify-no-changes --verbosity minimal --report "$proof\formats\review-info-$i" --include $Include
```

Initial fresh matrix evidence remains separately identified and
unchanged: Types274/Core119/Client151/server137 net10;
Types274/Core119/Client151/server135 net48, all passing. Qualification
base remains `0dff94bd378e79b8cf1a47e1aaf340f9b4b40048`; the initial
published head was `9fee4583`. All 16 initial exact command
arrays/results/TRX hashes remain in protected
`qualification-v2\qualification-summary.json`, SHA256
`88E10240C20049B59A3401BE007C6528A85DF2F3DCFAF9D11D908CEF2946DA4E`.

Failures/incidents remain retained rather than relabeled as success:
original disappeared-root historical counts are invalidated; the
large-TRX controller DOM-parser failure is preserved separately from
accepted streaming receipts; the review argument-array
defect/default-D-output controls are preserved but excluded from
canonical review qualification. Correctly separated argument rechecks
produced the accepted short-C Core/Client receipts. The sole SDK wrapper
was not changed.

Full UA.slnx, measured coverage increase, CLA verification, NativeAOT
publication and current-head/master CI/CodeQL remain unclaimed. Actual
merge—not resolved review threads—unlocks Wave B.

</details>
2026-10-13 06:25:43 +02:00
romanettandClaude Opus 5.5 0dff94bd37 Server quick wins from the o6 benchmark gap analysis: admission sizing, sampling permission cache, routed Read dispatch (#4743)
The o6-automation benchmark gap analysis listed several server quick
wins that need no wire or public API change. This PR implements three of
them. The 2.0 server now passes the benchmark's 10 clients × 1000
outstanding Reads test with the benchmark's own queue sizing. Its 100 Hz
sampling capacity grows about 7–10× on the same machine, and a
single-value Read allocates 8 % less.

## Results

Paired A/B against `master` (071660354): alternating builds, 3 rounds (5
for Read), medians, 24-core machine shared with other sessions.
Allocated bytes and CPU per operation are the reliable signal here.
Throughput varied up to 3× between rounds of the same build, so it is
listed only where the result is pass/fail.

| Scenario | Server alloc / op | Server CPU / op | Result |
|---|---|---|---|
| o6 `sub_client`, 2048 counters, 10 ms sampling, 1 s publishing |
**2309 → 111 B / sample (−95 %)** | **3.75 → 1.14 µs / sample (−70 %)**
| PASS on both |
| Harness `counters`, 2048 items, 10 ms / 1 s | 232 → 11.2 KB /
notification (−95 %) | −74 % | GC pause 644 → 24 ms in 12 s |
| **o6 sampling capacity** (largest count with every item ≥ 99 % of
samples, doubling from 1024, then bisect) | | | **2176–2304 →
16384–22528 items** (2 searches each) |
| o6 `client`, 10 × 1000 outstanding Reads, `MaxQueuedRequestCount =
10000` | | | **master fails 3/3 (BadServerTooBusy), this PR passes 3/3**
(31–36k req/s) |
| o6 `client`, 10 × 100 outstanding Reads | 7823 → 7556 B (−3 %) | ±0 |
|
| Read, 1 value (20 sessions) | 6534 → 6020 B (**−8 %**) | −1 % | |
| Read, 100 values | −1 % | ±0 | |
| Write, 100 values | −1 % | −5 % | |

For reference, open62541 (single thread, same Windows machine and native
client) sustained 5632 items in the gap analysis.

## Changes

### 1. `MaxQueuedRequestCount` admits that many established requests
(Balanced / TrustedReservations)
- `ServerResourceIsolationOptions.CreateRuntimePlan` carved the
bootstrap, reconnect, control and trusted floors out of the configured
queue, so established sessions could queue only `N − 3`. The default
totals of the decoded-request admission stages (RequestQueue,
RequestQueueBytes, ParkedRequest) now add the floors on top; the shared
capacity equals the configured value.
- `FairRequestQueue` takes its physical bound from the provider's stage
capacity (internal `IResourceIsolationCapacitySource`), so the protected
entries stay reachable when the shared part is full.
- RequestExecution keeps its floors inside the worker limit, so an idle
worker remains for protected requests. Explicit `Stages[].Capacity`
overrides keep their meaning as absolute totals including the floors.

### 2. Sampling without a role permission check per sample
- Since #4503 every sampling cycle called `ValidateRolePermissionsAsync`
for every item. For a `CustomNodeManager2` node that built full node
metadata (13-attribute read, namespace defaults via sync-over-async,
three nested node manager operations) only to return Good: about 80 % of
the 2.3 KB per sample.
- Each sampling loop now keeps per-item state between cycles:
ReadValueIds, result buffers and the last permission verdict. A verdict
is reused while the item's Session, effective identity and node handle
are unchanged and no permission change was signalled:
- `NodeState` bumps an internal version counter when `RolePermissions`
or `UserRolePermissions` of any node change (setter or Write);
- `IConfigurationNodeManager.DefaultPermissionsChanged` (namespace
defaults);
- a new `EffectiveIdentity` instance (ActivateSession with another user,
or roles refreshed after `RoleConfigurationChanged`).
- Independently, every item is revalidated once per second, spread over
the cycles, which bounds the delay for permissions that a node manager
computes without a signal (`OnReadRolePermissions`, custom
`ValidateRolePermissionsAsync`).
- The loop reuses its item snapshot until items are added or removed,
reads all items of one owner with one context instead of a LINQ
`GroupBy` (items moved to another session by TransferSubscriptions still
use the per-owner path), marks disabled items processed instead of
rebuilding the list, and checks for a detached loop without taking the
group lock.
- `Opc.Ua.Types` grants `InternalsVisibleTo` to `Opc.Ua.Server` for the
version counter.

### 3. Read and Write call only the node managers routed for the request
- `NodeManagerServiceDispatcher.ReadAsync`/`WriteAsync` called every
node manager. Every `AsyncCustomNodeManager` (configuration,
diagnostics, core, …) then copied the system context and allocated a
dictionary and a list, even with none of the requested nodes in its
namespaces.
- Validation now collects the namespace routes of the valid operations
and the dispatcher skips node managers outside them. Every manager of a
requested namespace is still called in dispatch order, so overlapping
managers keep their precedence. A namespace without a route still calls
every node manager, which keeps `BadNodeIdUnknown` and custom routing
unchanged.
- Not done: reusing the handle resolved during validation inside
`ReadAsync` needs a new node manager overload (public API), so nodes are
still resolved twice. HistoryRead and Call keep the old dispatch: Call
routes by object and method, and HistoryRead releases continuation
points per manager.

## Behaviour notes
- **`MaxQueuedRequestCount`** keeps its 1.5.x meaning again: established
sessions can have that many decoded requests waiting. Balanced and
TrustedReservations size the RequestQueue stage at
`MaxQueuedRequestCount + floors` (for example 10,003 for 10,000). A
deployment that relied on the smaller queue lowers
`MaxQueuedRequestCount` or sets the RequestQueue `Capacity`.
`docs/MigrationGuide.md`, `docs/ResourceIsolation.md` and
`docs/ServerScalability.md` are updated.
- **Permission changes for sampled items** still apply within one or two
sampling cycles when they come through
`NodeState.RolePermissions`/`UserRolePermissions`, namespace default
permissions, or a new effective identity. Permissions that a node
manager computes without changing those (for example
`OnReadRolePermissions` or a custom `ValidateRolePermissionsAsync`)
apply within one second instead of on the next sample.
`docs/NodeManagers.md` describes it.
- **Custom node managers** that process Reads or Writes of nodes in
namespaces they did not register receive those calls only while some
valid node of the request is in an unrouted namespace. Such a manager
registers its namespaces with `RegisterNamespaceManager` (documented in
`docs/NodeManagers.md`).

## Testing
- New unit tests:
- `RuntimeResourceIsolationTests`: plan sizing per mode, explicit
capacities, and a fair queue that admits exactly `MaxQueuedRequestCount`
requests from four owners, rejects the next and still admits bootstrap
and reconnect.
- `SamplingGroupPermissionCacheTests`: verdict reuse, revocation through
node RolePermissions in the same cycle, namespace default change,
identity change, periodic revalidation, disabled items. Each change is
injected inside the Read of a known cycle, so the tests do not depend on
timing.
- `NodeManagerDispatchRoutingTests`: non-owning managers are skipped,
several owners keep result order, an unrouted namespace still calls
everyone, two managers of one namespace keep their precedence, Write.
- net10.0: `Opc.Ua.Server.Tests` 8462 passed,
`Opc.Ua.Subscriptions.Tests` 643 passed, `Opc.Ua.Sessions.Tests` 851
passed / 379 skipped, `Opc.Ua.Core.Tests` (Stack.Server) 1858 passed,
`Opc.Ua.Stress.Tests` Contract|Integration 45 passed; 0 failures.
- net48 build of `Opc.Ua.Server.Tests` clean; `dotnet format whitespace`
clean.
- Measured with `perf/ServerLoadHarness` and the o6 scenarios (`o6read`,
`o6counters`) from the gap-analysis harness, which is not part of this
PR.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 08:52:09 +00:00
romanettandClaude Opus 5.5 25ebf5a4d8 Server: remove the server-wide locks from the request pipeline, bounded transport buffer pool (#4742)
# Server: remove the server-wide locks from the request pipeline,
bounded transport buffer pool

Quick wins #5 and #6 of the o6 benchmark gap analysis. Under 10 channels
× 100 outstanding single-value Reads on 24 cores, about 59 % of the
server's on-CPU time spun in `Monitor`/`Lock` slow paths, and roughly 20
of the ~30 lock acquisitions per Read were on server-wide locks in the
request pipeline. Separately, on hosts with one or two processors every
response allocated a fresh 64 KiB chunk, because
`ArrayPool<byte>.Shared` runs out with about a thousand responses in
flight.

No wire or configuration changes. The only public API change is an
additive `RequestLifetime` constructor. The admission *sizing*
(`MaxQueuedRequestCount` → shared capacity and reserved floors) is
untouched; this PR only changes how the accounting is locked.

## Results

Paired A/B against `origin/master` (f5062be34): the two harness builds
alternate per round on the same machine, the server runs Server GC, and
the table shows medians. o6read uses the o6-automation benchmark's
native open62541 client workers (10 clients × 100 in flight); the other
rows use the harness's .NET client with 20 sessions.

| Scenario | Rounds | req/s | Server CPU µs/req | Alloc B/req | p99 |
|---|---|---|---|---|---|
| o6read 10×100, 24 cores | 4 | 199,800 → 284,800 (**+43 %**) | 65.7 →
44.5 (**−32 %**) | 7,732 → 7,833 | – |
| o6read 10×100, 4-core mask | 6 | 105,300 → 102,600 (−3 %, ranges
overlap) | 37.8 → 38.8 (noise) | 7,824 → 7,636 | – |
| o6read 10×100, 1 core (`DOTNET_PROCESSOR_COUNT=1`) | 3 | 19,750 →
41,070 (**+108 %**) | 50.1 → 24.2 (**−52 %**) | **55,345 → 7,428** | – |
| read --nodes 1 --inflight 50, 24 cores | 4 | 250,200 → 355,100 (**+42
%**) | 50.3 → 31.0 (**−38 %**) | 6,551 → 6,448 | 8.8 → 10.3 ms |
| read --nodes 1 --inflight 50, 1 core | 3 | 45,300 → 89,550 (**+98 %**)
| 224 → 130 (**−42 %**) | 6,985 → 6,748 | 29.3 → 19.6 ms |
| read --nodes 100, 24 cores | 4 | 47,940 → 48,260 (+1 %) | 336 → 324
(−4 %) | 90,520 → 90,470 | 1.42 → 1.42 ms |
| browse, 24 cores | 4/3 | 53,110 → 51,840 (−2 %) | 214 → 218 (+2 %) |
44,450 → 44,280 | 1.92 → 1.58 ms |

Contention (`--trace-profile contention`, o6read 10×100, 24 cores, one
run per side): the isolation provider, `FairRequestQueue`, the request
queue's worker gate and `RequestManager` went from **26 % of all
contention events (~42k in the window) to 1.2 % (~2k)**, at 40 % higher
throughput. What remains is `CustomNodeManager2`'s lock and the
dispatcher's permission path (separate roadmap items). In the sampled
CPU profile, `Lock.TryEnterSlow` fell from 43 % to 2.7 % of on-CPU time.

The 4-core rows are within noise: with four processors these locks were
not the bottleneck. The read `--inflight 50` p99 at 24 cores rose with
42 % more requests in flight per second; at 1 core it fell by a third.

## Changes

### Bounded transport buffer pool (#6)
- `BoundedArrayPool` (internal): power-of-two buckets in lock-free
`ConcurrentQueue`s, at most 1024 arrays and at most 64 MiB in the
largest bucket (1024 × 64 KiB for the default `MaxBufferSize`), retained
memory below 2 × 64 MiB. Arrays are retained only once returned, so a
pool grows to the peak concurrency it has seen.
- One instance per maximum array length is shared by every client and
server buffer manager of the process, so client channels do not each
keep their own arrays.
- `ArrayPoolBufferManagerBase` uses it instead of
`ArrayPool<byte>.Shared` for buffer sizes up to 1 MiB (larger sizes keep
their configurable pool). `GetExpectedBufferSize` no longer doubles for
the next bucket on .NET Framework, since the bounded pool allocates the
exact bucket when it is empty.

### Isolation provider (#5)
- No provider-wide lock on `TryAcquire`/release. Stage counters, owner
usage and protected floors are reserved with bounded compare-and-swap
loops that never pass a limit; counters already reserved are returned if
a later one is full.
- The owner table is a `ConcurrentDictionary`. An owner that already
holds a lease of the same accounting class is admitted lock-free. A
class count crosses zero only under a table lock, which keeps the
exclusive class, the protected table slots and entry removal consistent
with the leases.

### FairRequestQueue (#5)
- The selected head is unlinked when it is selected, so an admitted
dispatch takes the gate once instead of twice.
- In weighted mode several workers select at once, instead of one
selector while the others go back to wait for a wake signal.
- A head that finds execution capacity exhausted is relinked in
admission order, and its owner is not retried before capacity changes
(as before).
- Global FIFO mode keeps one selection at a time, so its blocked head
still cannot be overtaken.

### ServerBase.RequestQueue (#5)
- Fair mode no longer takes the worker gate in
`ScheduleIncomingRequest`.
- `GrowFairWorkers` pre-checks the counters and takes the gate only to
add a worker.
- Running handlers are counted with `Interlocked`; shutdown's drain is
created after the workers exit and completed by the last handler.

### One cancellation source per request
- New public constructor `RequestLifetime(TimeSpan timeout,
CancellationToken first, CancellationToken second)`. It links only the
tokens that can be cancelled (a duplicate token once) and applies the
timeout with `CancelAfter` on the same source.
`EndpointIncomingRequest.CallAsync` uses it instead of a
`CancellationToken[]`, a separate TimeoutHint source and a linked source
over three tokens. The lifetime drops from 528 B to 240 B, and a timeout
still reports `Bad_Timeout`.
- `ServerBase.RequestQueue` no longer links the transport token again
for requests that link it themselves (internal
`ILinksScheduledCancellation`, implemented by
`EndpointIncomingRequest`). Other `IEndpointIncomingRequest`
implementations keep the linked token.
- Paired against the lock changes alone (4 rounds, 24 cores): allocation
−4 % (o6read 7,823 → 7,539 B, read `--nodes 1 --inflight 50` 6,446 →
6,164 B); CPU and throughput unchanged within noise.

### RequestManager (#5)
- Executing requests and validation scopes are `ConcurrentDictionary`s;
registration and completion take no lock.
- Drains publish a count before they snapshot, and a completion takes
the lock only when a drain is registered.
- The request timer is stopped only if no deadline was registered
meanwhile.
- The validation scope's `AsyncLocal` was write-only and is removed. The
request scope skips the ambient id when the dispatch scope already
carries it: 4 fewer `ExecutionContext` allocations per request.

## Behaviour notes
- **Admission races:** two acquisitions racing for the last units of a
stage may both be rejected where a serialized order would admit one. No
limit is ever exceeded, and the usage gauge never reads above capacity.
- **Weighted-mode ordering:** concurrent selections of one owner may
dispatch in a different order when one of them fails execution admission
while the other succeeds. Blocked requests are relinked in admission
order. Global FIFO mode is unchanged.
- **Cancellation and stop:** a request cancelled, or a queue stopped,
while the request's admission runs is completed exactly once by the
selecting worker (`Bad_RequestCancelledByClient` / `Bad_ServerHalted`),
as before.
- **Session close and Cancel:** registration ends with a full fence. A
Cancel records its pending entry before it scans the registered
requests, so a request registering concurrently is either swept or sees
the mark (the same guarantee the shared lock gave).
- **`ProcessRequestAsync` overrides:** a
`ServerBase.ProcessRequestAsync` override now receives the server's stop
token for endpoint requests instead of a token that is also linked to
the transport. The request lifetime that services observe is still
cancelled by the transport, the client and the timeout hint.
- **Memory:** the transport pool can retain up to 64 MiB per bucket size
after a peak, where `ArrayPool<byte>.Shared` would have trimmed it under
memory pressure. There is no trimming yet.

## Testing
- New tests:
- `BoundedArrayPoolTests`: bucket sizing, reuse, bound under concurrent
returns, a thousand outstanding buffers, a deep pipeline through
`FastBufferManager` without allocation.
- `ConcurrentLeasesStayWithinLimitsAndAreAllReturnedAsync`: 8 threads ×
20k operations with ordinary, bootstrap and reconnect owners and a
4-entry owner table. A mutation that lets the capacity check overshoot
by one fails it with 6,700 violations.
- `FairRequestQueueTests`: concurrent selection in weighted mode and not
in FIFO mode; relink in admission order with two threads (fails if
relinked at the head); stop during grant.
- `DrainsRacingLockFreeRegistrationAndCompletionAlwaysCompleteAsync`:
200 drains against four threads registering and completing requests and
validation scopes. It times out if completions skip drains.
- Suites (net10.0): Opc.Ua.Core.Tests Stack.*, Opc.Ua.Server.Tests,
Opc.Ua.Sessions.Tests, Redundancy Server/Client/Samples, Stress Contract
+ Integration:

  | Suite | Passed | Failed | Skipped |
  |---|---|---|---|
  | Opc.Ua.Core.Tests `Stack.*` | 3604 | 0 | 80 |
  | Opc.Ua.Server.Tests | 8447 | 0 | 5 |
  | Opc.Ua.Sessions.Tests | 852 | 0 | 379 |
  | Opc.Ua.Redundancy.Server.Tests | 884 | 0 | 0 |
  | Opc.Ua.Redundancy.Client.Tests | 134 | 0 | 0 |
  | Opc.Ua.Redundancy.Samples.Tests | 17 | 0 | 0 |
  | Opc.Ua.Stress.Tests `Contract\|Integration` (Release) | 45 | 0 | 0 |
- `dotnet format whitespace --verify-no-changes` on the changed files;
net48 build.

Not covered: `CustomNodeManager2`'s lock and the channel gate (separate
roadmap items); admission sizing is in a parallel PR.

Remaining per-request cancellation cost: the request lifetime links the
server's stop token and the transport token in one source (240 B), so
that shutdown and a closed channel still cancel running requests.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 08:39:27 +00:00
romanettandClaude Opus 5.5 28f1401324 Server: cut request-path lock contention and allocation; add load harness (#4707)
Profiling the reference server under load showed most request-path CPU
spent spinning on contended locks, plus several avoidable allocations
per request or per node. This PR removes them, switches the reference
server to Server GC, and adds the load harness used to measure it.

## Results

Paired A/B against `master` (3 alternating rounds, medians, 20 sessions,
workstation GC on both sides, 24-core machine):

| Scenario | Throughput | p50 | p90 | Server CPU / op | Server alloc /
op | Lock contention |
|---|---|---|---|---|---|---|
| Read, 1 value | **+45%** (94k → 137k req/s) | −20% | −46% | −37% |
−39% | −94% |
| Read, 100 values | **+40%** | −26% | −17% | −24% | −25% | +130% ¹ |
| Read, 100 values, SignAndEncrypt | **+48%** | −36% | −20% | −30% |
−26% | +106% ¹ |
| Browse (Server object) | **+33%** | −31% | −12% | −13% | −14% | −40% |
| Write, 100 values | +13% | −12% | −4% | −10% | −19% | −23% |
| Data-change subscriptions | ±0 ² | ≈ | ≈ | −14% | −17% | −29% |

¹ The faster server spends more time in the remaining per-node locks.
² Throughput is bounded by the writer rate in this scenario.

Separately, Server GC instead of workstation GC doubles Read-100
throughput (21k → 44k req/s) and cuts p90 from 2.1 ms to 0.6 ms, because
workstation GC paused the server for about half the wall time under
load. The working set grows from ~210 MB to ~430 MB.

## Changes

- **`ObjectPool<T>`**: lock-free slot array. `ConcurrentBag.Count` took
every per-thread lock on each `Return` of the pooled response value-task
source, once per request.
- **`FairRequestQueue`**:
- drops redundant gate entries: the fail-fast pre-checks and the stopped
check read volatile fields and are re-checked under the gate; the owner
count is folded into selection;
- coalesces wake pulses with a pending flag, so the bounded signal
channel's lock is only taken when no token is outstanding. Wake-count
semantics in the tests are unchanged.
- **`DefaultServerResourceIsolationProvider`**:
  - compares peer addresses without `GetAddressBytes()` allocations;
- builds the unsecured verification context only when a classifier is
configured.
- **TCP listener / channel**: caches the certificate DER copies per
channel, keyed on the certificate instance. `X509Certificate2.RawData`
returns a fresh copy on every access, and the listener read it for every
request.
- **`EndpointBase`**: resolves the request `ActivitySource` once instead
of `Assembly.GetCallingAssembly()` per request.
- **Node managers**:
- one `ServerSystemContext` copy per request (cached on the
`OperationContext`) for per-node callbacks: permission metadata, node
metadata, view membership, method lookup;
  - removes a discarded copy in `GetReferenceDescriptionAsync`;
- per-request operation caches are plain `Dictionary` instead of a
`ConcurrentDictionary` with 24 lock objects;
  - the validation cache is built in one pass;
  - fixes zero-capacity error lists.
- **`ResourceManager`**: translation lookups read an immutable snapshot
that is rebuilt after writes. Browse serialised on the global lock for
every display name.
- **`MonitoredItem`**: shares the default `DataChangeFilter` instead of
allocating one per sampled value.
- **Reference server**: `ServerGarbageCollection=true`; new "Garbage
collector mode" section in `docs/ServerScalability.md`.
- **`perf/`**: `ServerLoadHarness` (out-of-process server plus load
driver, server-side CPU/GC/alloc/contention deltas, optional
`dotnet-trace` capture), `TraceAnalyzer` (TraceEvent-based
CPU/allocation/contention aggregation), and paired A/B scripts. Not part
of `UA.slnx`.

### Behaviour notes for custom node managers
- The `IDictionary<NodeId, NodeState>` operation cache handed to
overrides is now a `Dictionary`. It was a `ConcurrentDictionary`, but
only one request flow uses it.
- Within one request, per-node callbacks of a node manager share one
`ServerSystemContext` copy.

### Later commits on this PR
- **Permission checks without `NodeMetadata`** (`9068684c3`): the
role-permission and access-restriction validators run on an internal
`PermissionMetadata` struct; `AsyncCustomNodeManager` fills it on the
stack from the same attributes and namespace defaults. Browse's
reference filter reads 3 attributes per target instead of 13. Read 100
values: 120.6 → 95.7 KB/request, ~21k → 26k req/s; Browse 67.6 → 51.8
KB, 26.7k → 29.8k req/s.
- **Read-only channel evidence** (`936b28d76`, `dc638ab9f`):
`SecureChannelContext.ClientChannelCertificate`,
`ServerChannelCertificate` and `ChannelThumbprint` are `ByteString`, so
the cached per-channel certificate buffer is shared without a copy and
cannot be altered by handlers or classifiers. **Public API change**, see
the new section in `docs/MigrationGuide.md`. The isolation provider's
revalidation snapshot still copies.
- **Session lookup** (`936b28d76`):
`SessionManager.TryGetSessionContext` takes the candidate snapshot
without the bindings lock; the validating check under the lock is
unchanged (2 instead of 4 global lock acquisitions per request).
- Unit tests for `ObjectPool`, the per-request system context cache, the
translation snapshot, `SecureChannelContext` null semantics and a
stopped `FairRequestQueue`; `perf/` follows the repository conventions.

## Testing
- `Opc.Ua.Server.Tests` (net10.0): 8386 passed before the `ByteString`
change. After it, 13 secure-session and isolation tests failed; both
causes are fixed in `dc638ab9f` and those tests (25 with their variants)
pass. A full rerun of the Server, Sessions, Core, Redundancy, LDS and
GDS suites on the final commit is in progress.
- Full solution builds for net10.0 and net48; `dotnet format whitespace`
clean.

### o6-automation opcua-benchmarks (sampling, deep pipelines)
Replicated both tests of the [o6-automation
benchmark](https://github.com/o6-automation/opcua-benchmarks) in the
harness (`--scenario counters`, and `read` with 10 clients × N
outstanding), paired against `origin/master`:

| Test | master | this PR |
|---|---|---|
| Subscription sampling, 100 Hz, every item ≥ 99 % of samples, 1 item |
FAIL (75–94 %) | PASS (100 %) |
| same, 1024 items | FAIL (64–73 %) | PASS (99.9 %) |
| same, capacity | — | between 4096 and 6144 items (~410–520k samples/s)
|
| Read, 10 clients × 100 outstanding, None | stalls (0–1.7k req/s) |
121–131k req/s |
| Read, 10 clients × 100 outstanding, SignAndEncrypt | stalls (0 req/s)
| 89–91k req/s |
| Read, 10 clients × 1000 outstanding | channels faulted
(`BadTcpNotEnoughResources`) | 0 errors, ~45–54k req/s |

- **Fixed-rate sampling** (`30b769d13`): the sampling loop waited one
interval after each sample, adding timer overshoot and sampling time to
every cycle; samples now have absolute deadlines.
- **Async response send** (`cdac5c5ca`): `SendResponse` blocked
thread-pool workers on the channel gate; with many requests in flight
the receive loop that was handed the gate waited for a pool thread and
the channel stalled. The TCP, Kestrel TCP and WebSocket listeners now
use `SendResponseAsync`.
- **Backpressure instead of channel fault** (`191cd2ba7`): server
channels stop reading while more than 100 responses are queued and
resume at 50, instead of faulting the channel. Client channels keep
reading. At 10,000 outstanding requests throughput is ~50k req/s with or
without the limit engaged, so the remaining gap to the 100-per-client
case is not the backpressure.

## Not changed
- `SentMessageQueue` deep-clones every `NotificationMessage`. Retained
messages return their pooled payloads on acknowledgement while the
response copy may still be queued for send, and a client can acknowledge
a sequence number before receiving it; the clone keeps a recycled
payload from reaching another session's response. Removing it needs a
send-completion hook from the transport.
- Generated responses allocate a default `ResponseHeader` that is
replaced (~2-3 % of per-request allocation). Fixing it means making
structure fields of every generated type lazily initialized, a
generator-wide change to default values, equality and encoding.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:00:49 +02:00
romanettandClaude Opus 5.5 9b034f8291 Enable nullable reference types centrally; migrate all unset and annotations-only projects (#4700)
# Description

Nullable reference types are now enabled once, in `common.props`, and
every project in the repository builds warning-clean with them.

**Configuration**
- `<Nullable>enable</Nullable>` lives in `common.props`. All per-project
`<Nullable>` settings are removed: 178 project files (enable,
annotations, and the `disable` in the three Aot test projects).
- 419 per-file `#nullable enable`/`restore` directives and the 4
`#nullable disable` directives are removed.
- Kept on purpose:
- The 5 `<auto-generated>` schema files (`UANodeSet.cs`, `UA Type
Dictionary.cs`, `OPCBinarySchema.cs`, `SecuredApplication.cs`,
`PInvokeHelper.cs`) keep `#nullable enable`. The compiler ignores the
project setting in generated code, so the directive is required there.
- `tests/Opc.Ua.Interop.LegacyPeer` keeps `disable`. It builds against
the 1.5.378 packages and does not import the repository props.
- `docs/DeveloperGuide.md` and `.github/copilot-instructions.md` now
describe the central setting.

**Migration** (the 43 unset and 13 annotations-only projects, roughly
10k diagnostics)
- **Production code: annotated at the declarations.** Covers
`tools/Opc.Ua.SourceGeneration.Core`, `src/Opc.Ua.Lds.Server` and the
LDS sample.
- Members, parameters and returns are nullable where null actually
flows.
- Null-check helpers carry `[NotNullWhen]` (`IsNull`, `IsValidXmlName`,
`IsMethodTypeDesign`).
- `CoreUtils.Format`, `Utils.Format`, `ITemplateWriter.WriteLine` take
`params object?[]`. `Template.AddReplacement` takes `string?` (rendering
already skips null replacements).
- `IRegisteredServerStore.RegisterAsync` returns
`Task<RegistrationEntry?>`: it returns null when a registration is
removed.
- Dereferences backed by an invariant (for example, the validated model)
use `!`, so runtime behaviour is unchanged.
- **Behavioural changes in production code** (small, deliberate):
- `NodeSetToModelDesign` throws `InvalidOperationException` when the
file is not a NodeSet. Before, it threw a NullReferenceException on the
next line.
- `ModelDesignValidator` starts its lookup tables empty instead of null.
A redundant `if (m_nodes == null) return;` guard is gone.
- **Test code: minimal annotations.**
  - `?` on values that can be null, `!` where the test asserts non-null.
  - `= null!` for fixture members assigned in SetUp.
- `TryGetStructure<T>(out T? x)` where the inferred `T?` broke the
`IEncodeable` constraint.
- A few redundant `== null` checks on structs (`NodeId`, `ArrayOf<T>`)
folded into the adjacent `IsNull`/`Count` checks.

**Notes for reviewers**
- The diff is large (~1,500 files) but almost entirely mechanical: `?`,
`!` and directive removal. The non-annotation changes are listed above.
- Known follow-up, not done here:
`IModelDesign.Namespaces/TargetNamespace/NamespaceUris` stay
non-nullable even though the validator returns null before it loads a
dictionary. Making them nullable would ripple through every generator
call site.

## Related Issues

- Fixes #4439

## 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.
- [ ] 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.
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [ ] I have addressed **all** PR feedback received.

The "no new warnings" box covers net10.0 and net48: full non-incremental
builds of `UA.slnx`, `tools/SourceGeneration.slnx`,
`tools/MigrationAnalyzer.slnx` and the Aot test projects finished with 0
warnings and 0 errors under the repository's warnings-as-errors
settings. net9.0 and net8.0 also had no nullable diagnostics, but they
were not rebuilt after the final fixes. Tests have not been run locally
yet.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 08:48:13 +02:00
f9892020a3 Add configurable server resource isolation (#4541)
# Description

Add configurable server resource isolation on top of the merged #4522
foundation.

- Coordinate connection, handshake, retained-message,
session-establishment, queue, execution, request-cost and parked-request
limits through one server-scoped provider.
- Keep configured startup, continuity/reconnect, control and
trusted-owner reservations inside existing totals. Shared capacity
remains usable up to explicit owner ceilings.
- Use authoritative Session membership for reassembly continuity
capacity without granting unrelated resource or rate privileges.
Preserve explicit owner mappings and bounded tracking.
- Cache immutable classification evidence while retaining current
Session and classifier checks.
- Support weighted request ordering or FIFO independently of resource
accounting. Installed providers enforce decoded-request limits in both
modes.
- Preserve fail-fast reassembly, valid Abort handling, fixed deadlines,
active-work protection, reverse-connect handoff and bounded OpenAPI
processing.
- Support custom-handler parking through `IRequestParkingPolicy`,
`WithRequestParking(...)`, and `RequestLifetime.ParkSink`. Parking
releases execution capacity only, keeping retained-data and parked
charges until completion. Built-in Publish and the global opt-out remain
unchanged.
- Provide direct, fluent and DI configuration, public incoming-request
parking support, effective startup limit logs and user-facing
documentation.

Balanced remains the default. SharedOnly without an explicit provider
retains shared behavior for intentionally incompatible/unlimited
configurations. Reservations are finite and are not one full-message
allowance per Session.

## Related Issues

Builds on merged #4522. Fixes #4554.

## Review and CI corrections

All three previously missed findings in [review
5326734718](https://github.com/OPCFoundation/UA-.NETStandard/pull/4541#pullrequestreview-5326734718)
are addressed in `9e25e4ecc`:

1. A custom FIFO provider receives RequestQueue, RequestQueueBytes,
RequestExecution and ParkedRequest accounting. A blocked head cannot be
overtaken.
2. `IResourceIsolationReassemblyProvider` replaces the sessionless
threshold independently of `UseFairScheduling`. The aggregate byte
ceiling remains enforced.
3. Runtime `ReservedReassemblyBytes` includes all configured protected
stage floors, including ControlReserved.

The review's two already-resolved changes remain intact.

The CI collection race was fixed at its source in `e1a994a4b`:
`ArrayOf.ToArrayOf` treats a separately read collection count as an
initial capacity hint and grows/trims for the actual enumeration.
Deterministic tests cover growth, shrinkage and stale-zero counts.

The subsequent recovery-test race was fixed in `05753ce77`: fake time
advances only after a gated keepalive response has been processed and
reset its timer, not merely after request arrival. Recovery-order and
timeout assertions remain intact, with additional response-state
assertions. Instrumentation reproduced the original failure twice; both
fixed variants passed 400 executions per framework.

CodeQL alert 388 was verified and dismissed as a false positive. Startup
logs contain aggregate numeric capacities, not credentials or caller
identities.

## Validation

| Scope | Result |
| --- | --- |
| Latest automated checks at `05753ce7775a58801b4ba9d8f6d7781b709d0b89`
| 167 successful, one package-publication check skipped, no failures |
| Remaining external prerequisite | `license/cla` pending: Contributor
License Agreement is not signed yet |
| FIFO/weighted queues, custom parking and server-channel selection |
175 passed on each net10.0/net48 |
| Isolation, configuration and parking-hosting selection | 259 passed
net10.0; 257 passed net48 |
| ArrayOf conversion tests | 73 passed on each net10.0/net48 |
| Recovery-audit and keepalive fixtures | 40 passed on each
net10.0/net48 |
| NativeAOT smoke from preceding updates | Published and executed
successfully |

No tests, assertions, coverage thresholds, or required checks were
weakened. The remaining CLA prerequisite must be completed through [CLA
Assistant](https://cla-assistant.io/OPCFoundation/UA-.NETStandard?pullRequest=4541).

This is not CTT certification or an unconditional network-DDoS,
whole-process-memory or fleet-wide guarantee.

## 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.
- [ ] 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.
- [x] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [x] I have addressed **all** PR feedback received.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: marcschier <11168470+marcschier@users.noreply.github.com>
2026-09-27 21:11:52 +02:00