392 Commits

Author SHA1 Message Date
Wolfgang Colsman 716858aa7d Source generator: option to emit exactly the nodes of a NodeSet2 (#4797)
# Description

For a NodeSet2 input the generator built the predefined nodes from the
merged instance hierarchy. A subtype therefore got a copy of every
Mandatory InstanceDeclaration of its supertypes as a new child node,
with
a NodeId the NodeSet2 never assigned (the supertype's numeric identifier
in the subtype's namespace, or a string identifier derived from the path
when the supertype comes from a referenced assembly). A DeviceType
subtype got eight such children from DI's DeviceType, so the address
space
a server loads from the generated code differs from the NodeSet2 it was
generated from.

ModelSourceGeneratorEmitNodeSetNodesOnly (GeneratorOptions
.EmitNodeSetNodesOnly), off by default, makes the predefined nodes of a
NodeSet2 input exactly the nodes the NodeSet2 declares: a child the
generator derives from a supertype or type definition is not added to a
predefined node. The typed state classes are unchanged and still reach
inherited members through the state classes of their supertypes, and the
CreateInstanceOf* factories still create every Mandatory child of the
type. ModelDesign inputs are not affected. IModelDesign.IsSourceNodeSet
tells the generator whether the target model was read from a NodeSet2.

A test in Opc.Ua.Di.Tests generates a DI-style NodeSet2 with the option
on, exports the generated predefined nodes as a NodeSet2 and compares it
with the input, node for node and reference for reference.

## Related Issues

None.

## Checklist

- [x] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [x] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [x] I have added all necessary documentation.
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [x] 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.
- [ ] I have addressed **all** PR feedback received.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Marc Schier <marcschier@users.noreply.github.com>
2026-10-16 12:38:19 +02:00
Marc Schier 87012a147f Machinery: add bounded inspection and result transfers (#4804)
# Description

- Add opt-in bounded browsing and strict reads with original source,
status, timestamp, and structured-value evidence. Preserve the existing
tolerant constructors and convenience projections, and wire options
through session extensions, factories, and hosting registration.
- Add selected-result streaming downloads with byte/request limits and
independent cleanup, using `TemporaryFileTransferClient.CopyToAsync`
from the client/server predecessor. Retain the detailed transfer
regressions in the Machinery test layer.
- Expose genuinely null unpublished result slots, carry the two
Machinery sample updates, and include the complete Machinery guide and
package README changes.

## Related Issues

- Related tracking issue: #4461; this extraction does not close it.
- Frozen source PR: #4462; this extraction does not close or modify it.
- Immediate predecessor: #4803 (`marcschier-extract-amb-changes`). The
earlier dependencies are client/server #4802 and core/types #4801.

## 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.
- [ ] I have addressed **all** PR feedback received.
2026-10-16 12:24:35 +02:00
Marc Schier 8772f37275 Fix recycling scenario defaults and MCP interoperability (#4808)
# Description

Fix five failures reproduced while exercising the recycling sample
through a locally built OPC UA MCP server:

- Support Certificate authentication through named, host-approved X.509
identity selectors and the existing certificate/identity providers. Tool
arguments cannot supply private keys or arbitrary certificate-store
paths.
- Register custom enumeration dependencies from the validated, bounded
type snapshot before loading requested structure roots.
- Preserve structured fields and opaque encoded bodies in generic
read/call results using the selected session's UA codecs.
- Add bounded, explicitly typed UA JSON method arguments while retaining
the existing string argument behavior and C# overloads.
- Preserve omitted defaults in sparse recycling scenario JSON; explicit
invalid values and unknown members still fail validation. All six
shipped scenario files are covered by regressions.

Update the MCP and recycling documentation. This PR contains one fix
commit on a feature branch in **OPCFoundation/UA-.NETStandard**, based
directly on upstream `master` at `95850b7e5`. No local registration,
certificates, scenario journals or live-test artifacts are included.

### Validation

**Final commit `d199feba0`: 216 affected MCP tests and 16 recycling
CLI/scenario tests passed on .NET 10**, with no failures, fixture
setup/teardown errors or build/analyzer warnings. The repository's TRX
verdict helper accepted both reports.

**Draft: the full-solution gate is not green.** The .NET 10 solution run
encountered an `Opc.Ua.OneFuzz.Validator.Tests` fixture-publication
failure outside the files changed by this PR: `MSB3030`, missing
`Opc.Ua.OneFuzz.TestDependency.runtimeconfig.json`. It affected 210
tests; the run was stopped rather than expanding this PR into unrelated
OneFuzz work. The full .NET Framework 4.8 solution suite was not run.

Before the final rebase, the complete managed recycling suite passed
**117**, and the CLI/scenario tests passed **16 each on .NET 8 and .NET
9**, with no failures or fixture teardown errors. The five explicit
NativeAOT smoke cases were not selected.

Actual live qualification used one coordinator and all ten independent
process servers with strict certificate trust. Healthy, combined-fault
and zero-utility runs each produced 24 complete packages with exact
component conservation. Also exercised native companion discovery,
subscriptions, authorization, pause/step/enable/disable, durable request
replay, quality holds, reset and coordinator crash recovery. NativeAOT
executables, container deployment and parity against a complete external
producer contract were not qualified.

<details>
<summary>Exact validation commands and results</summary>

Commands ran from the repository root with an isolated .NET SDK
10.0.401. `$root` denotes the private validation-artifact directory; no
machine-specific path or key material is included here.

Affected MCP matrix, final commit — **passed: 216; failed: 0; skipped:
0**:

```powershell
dotnet test tests\Opc.Ua.Tools.Tests\Opc.Ua.Tools.Tests.csproj -c Release -f net10.0 -p:CustomTestTarget=net10.0 -p:CustomTargetFrameworks=net10.0 -p:UseSharedCompilation=false -m:1 -nr:false --no-restore --nologo -v:quiet --filter 'FullyQualifiedName~SchemaRemoteTests|FullyQualifiedName~OpcUaJsonHelperTests|FullyQualifiedName~MethodServiceToolsTests|FullyQualifiedName~ConvenienceToolsTests|FullyQualifiedName~AttributeServiceToolsTests|FullyQualifiedName~ViewServiceToolsTests|FullyQualifiedName~OpcUaSessionManagerTests|FullyQualifiedName~ConnectionToolsTests|FullyQualifiedName~McpHostBuilderTests|FullyQualifiedName~ConfigurationToolsTests' --logger 'trx;LogFileName=mcp-pr.trx' --results-directory "$root\pr-validation\focused"
```

.NET 10 CLI/scenario regressions, final commit — **passed: 16; failed:
0; skipped: 0**:

```powershell
dotnet test tests\Opc.Ua.Recycling.Samples.Tests\Opc.Ua.Recycling.Samples.Tests.csproj -c Release -f net10.0 -p:CustomTestTarget=net10.0 -p:CustomTargetFrameworks=net10.0 -p:UseSharedCompilation=false -m:1 -nr:false --no-restore --nologo -v:quiet --filter 'FullyQualifiedName~RecyclingCommandLineTests' --logger 'trx;LogFileName=recycling-cli-pr.trx' --results-directory "$root\pr-validation\focused"
```

Complete managed recycling suite, before rebase — **passed: 117; failed:
0; skipped: 0** (five explicit NativeAOT cases not selected):

```powershell
dotnet test tests\Opc.Ua.Recycling.Samples.Tests\Opc.Ua.Recycling.Samples.Tests.csproj -c Release -f net10.0 -p:CustomTestTarget=net10.0 -p:CustomTargetFrameworks=net10.0 -p:UseSharedCompilation=false -m:1 -nr:false --no-restore --nologo -v:quiet --logger 'trx;LogFileName=recycling-final.trx' --results-directory (Join-Path $root 'results')
```

.NET 8 CLI/scenario regressions, before rebase — **passed: 16; failed:
0; skipped: 0**:

```powershell
dotnet test tests\Opc.Ua.Recycling.Samples.Tests\Opc.Ua.Recycling.Samples.Tests.csproj -c Release -f net8.0 -p:CustomTestTarget=net8.0 -p:CustomTargetFrameworks=net8.0 -p:UseSharedCompilation=false -m:1 -nr:false --nologo -v:quiet --filter 'FullyQualifiedName~RecyclingCommandLineTests' --logger 'trx;LogFileName=recycling-cli-net8.trx' --results-directory "$root\results"
```

.NET 9 CLI/scenario regressions, before rebase — **passed: 16; failed:
0; skipped: 0**:

```powershell
dotnet test tests\Opc.Ua.Recycling.Samples.Tests\Opc.Ua.Recycling.Samples.Tests.csproj -c Release -f net9.0 -p:CustomTestTarget=net9.0 -p:CustomTargetFrameworks=net9.0 -p:UseSharedCompilation=false -m:1 -nr:false --nologo -v:quiet --filter 'FullyQualifiedName~RecyclingCommandLineTests' --logger 'trx;LogFileName=recycling-cli-net9.trx' --results-directory "$root\results"
```

Full .NET 10 solution — **not passed**: stopped after the OneFuzz
fixture failure above; incomplete solution run, not a clean validation
result:

```powershell
dotnet test UA.slnx -c Release -p:CustomTestTarget=net10.0 -p:CustomTargetFrameworks=net10.0 -p:UseSharedCompilation=false -m:1 -nr:false --nologo -v:quiet --logger trx --results-directory "$root\pr-validation\net10"
```

</details>

## Related Issues

Follow-up to
[#4772](https://github.com/OPCFoundation/UA-.NETStandard/pull/4772),
which introduced the recycling sample. No separate tracking issue is
currently linked. These fixes preserve the existing architecture and
address failures reproduced during live qualification.

## 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.
- [ ] I have addressed **all** PR feedback received.

Regression tests were added and passed as recorded above; aggregate
coverage was not collected, so the combined coverage checklist item
remains unchecked.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: marcschier <11168470+marcschier@users.noreply.github.com>
2026-10-16 11:41:51 +02:00
romanett 68f062eecc Server request path: one response header, single-node Read without collections, codec streams reused per thread (S1-S6, -38 % allocation per Read) (#4812)
## Description

Six small changes to the per-request path from the o6 benchmark gap
report (S1–S6), chosen from the allocation profile of the o6 read
benchmark (`AsyncCustomNodeManager`, Server GC, 10 clients × 100
single-value Reads in flight). Each change is its own commit. The
receive loop, its threading and its dispatch are not touched; there are
no wire, public API or generator changes.

### Results

Paired A/B runs, builds alternated per round, 5 rounds unless noted,
medians (min..max). Baseline: master d40843c23 with #4787, #4788 and
#4805 (all three merged since; this branch is rebased on master). Each
item was measured on its own on the baseline, and all six together. The
machine is shared with other sessions: a run during which another test
host or load harness ran was discarded and repeated. Allocated bytes per
request are deterministic; CPU per request is best read from the runs
pinned to 4 logical processors (`--server-affinity 0xF`, the 4 Zen 5
cores).

**Allocated bytes per request** (o6read 10 × 100, pinned):

| Node manager, security | baseline | S1 | S2 | S3 | S4 | S5 | S6 | all
|
|---|---|---|---|---|---|---|---|---|
| async, plain | 4,219 B | 4,114 (−2.5 %) | 3,631 (−13.9 %) | 4,057
(−3.8 %) | 4,123 (−2.3 %) | 4,099 (−2.8 %) | 3,511 (−16.8 %) | **2,411
(−42.9 %)** |
| async, encrypted | 4,267 B | −2.6 % | −13.8 % | −3.7 % | −2.4 % | −3.1
% | −16.5 % | **2,465 (−42.2 %)** |
| `CustomNodeManager2`, plain | 5,407 B | −2.1 % | −5.1 % | −3.0 % |
−1.8 % | −2.5 % | −12.9 % | **3,910 (−27.6 %)** |
| `CustomNodeManager2`, encrypted | 5,457 B | −2.0 % | −5.1 % | −2.9 % |
−1.9 % | −2.4 % | −12.9 % | **3,963 (−27.4 %)** |

**CPU per request, pinned to 4 logical processors:**

| Node manager, security | baseline | all six | each item alone |
|---|---|---|---|
| async, plain | 28.3 µs (27.8..29.8) | **27.1 µs (25.6..27.7), −4.2 %**
| −2.7 .. +1.9 % |
| async, encrypted | 31.5 µs (30.8..32.9) | 30.6 µs (30.1..40.1), −2.8 %
| −2.8 .. +0.8 % |
| `CustomNodeManager2`, plain | 34.0 µs (33.1..36.6) | **31.2 µs
(30.5..34.1), −8.1 %** | −5.0 .. +2.5 % |
| `CustomNodeManager2`, encrypted | 35.8 µs (35.0..38.9) | **34.3 µs
(33.5..35.2), −4.1 %** | −1.0 .. +3.8 % |

Garbage collections in the pinned plain run (300,000 requests): gen0 19
→ 12, pause 85 → 50 ms.

**After review (S6 with immutable codecs):** the tables above were
measured with a decoder and an encoder reset per message. Keeping
`BinaryDecoder` and `BinaryEncoder` immutable, so every message gets new
ones, costs about 214 bytes per Read: all six together allocate 2,625 B
per Read instead of 2,411 B in the pinned plain run (2 runs), −37.8 %
against the baseline.

**24 cores and the managed client** (all six against the baseline; the
managed scenarios 3 rounds):

| Scenario | baseline | all six |
|---|---|---|
| o6read 10 × 100, 24 cores: req/s | 296.3k (283.4..305.4k) | 302.2k
(245.0..323.0k) |
| same: allocated / request | 4,169 B | 2,349 B (−43.7 %) |
| o6read 10 × 1000, 24 cores: req/s | 297.4k (284.2..309.8k) | 295.6k
(269.6..311.5k) |
| same: allocated / request | 4,196 B | 2,373 B (−43.4 %) |
| `read --nodes 1`, 20 sessions: req/s, CPU / request | 259.3k, 40.2 µs
| 271.4k, 38.9 µs (−3.2 %) |
| same: allocated / request | 4,172 B | 2,444 B (−41.4 %) |
| `read --nodes 1`, 200 sessions: req/s, CPU / request | 257.1k, 36.7 µs
| 283.6k, **33.1 µs (−9.8 %)** |
| same: allocated / request | 4,041 B | 2,354 B (−41.7 %) |
| `read --nodes 100`: CPU / request | 226.1 µs | 214.9 µs (−5.0 %) |
| same: allocated / request | 72,379 B | 52,046 B (−28.1 %; S2 alone
−26.3 %) |
| `browse`: CPU / request (6 rounds) | 147.9 µs | 148.8 µs (+0.6 %) |
| same: allocated / request | 38,234 B | 32,603 B (−14.7 %) |
| `sub`: allocated / notification | 126 B | 121 B (−4.0 %) |

**Reading the CPU numbers.** Every item lowers the allocation per
request by a deterministic amount, and the savings add up (the six items
alone sum to −42.1 % against −42.9 % together). CPU per request follows
only partly: each item alone changes it within the run-to-run noise (±3
%); all six together lower it by 3–10 %, with the min..max ranges
separated in the pinned plain async and the `CustomNodeManager2`
encrypted runs and in `read --nodes 1` with 200 sessions. With Server GC
on 4 cores, garbage collection is a small share of the CPU, so 40 %
fewer bytes buy a few percent of CPU. At 24 cores throughput and CPU per
request are dominated by where the scheduler places the threads (Zen 5
and Zen 5c cores) and do not resolve a change of this size.

Allocation trace (o6read 10 × 100, first `Opc.Ua` frame): gone are the
`ResponseHeader` from `ServerBase.CreateResponse`, `Dictionary<NodeId,
NodeState>` (`AsyncCustomNodeManager.ReadAsync`), one of two
`NodeHandle`s, `Variant[]`/`uint[]` (permission attributes),
`List<DataValue>`/`List<DiagnosticInfo>`/`DiagnosticInfo[]`,
`ArrayOf<ExtensionObject>` (`Variant.Copy`), `RolePermissionType[]`,
`SecureChannelContext`, `PendingAdmission`, `System.Action`,
`RequestExecutionScope`, the `DequeueAsync` state machine box,
`MemoryStream`/`BinaryReader` of the decoder, `ArraySegmentStream`,
`BinaryWriter` and one `BufferCollection`.

## Changes

**S1: one `ResponseHeader` per response.** The generated response
constructors already create a default `ResponseHeader`; the services
replaced it with the one `ServerBase.CreateResponse` allocated. The
services that construct their response now offer its default header, and
the base `CreateResponse(RequestHeader, StringTable)` fills it. The
offer goes through a field of the thread, so the protected virtual keeps
its signature: an override that does not call the base returns its own
header as before, and a re-entrant call never gets the outer offer. The
services that receive their response from the subscription manager keep
the previous path. The third `ResponseHeader` in the profile is the
generated `Reuse()` resetting the response after it was sent. It stays:
`LeasedPublishResponse` releases its notification lease there, and the
reset is generated code.

**S2: a single-node Read without collections.**
- The dispatcher fills arrays instead of lists that were copied into the
response (the node managers fill them by index), and allocates the
operation diagnostics only for the first one.
- `AsyncCustomNodeManager.ReadAsync` creates its per-request node cache
only for a request with several nodes, or when a node must be validated.
- The permission check already resolves every node through
`GetManagerHandleAsync`, whose handle "does not require the NodeId to be
validated again". The Read service passes that handle on in
`ReadValueId.Handle` when it is an in-memory node of an
`AsyncCustomNodeManager` that was validated when it was returned, and
the node manager reads through it instead of looking the node up and
allocating a second handle. A handle passed on for an earlier read of
the same `ReadValueId` is cleared.
- The permission check without an attribute cache reads the three
permission attributes into arrays of the thread.

**S3: no copy of empty arrays.** `Variant.Copy` deep-copies arrays of
ExtensionObjects, DataValues and Variants because the caller can change
their elements. An empty array has no element to change but was copied
into a new boxed empty array. The Read service copies the namespace's
DefaultRolePermissions and DefaultUserRolePermissions on every
permission check (the variables' `CopyOnRead` policy); they are empty
unless roles are configured. `TryGetStructure` of an empty array returns
the empty array instead of a new one. Non-empty arrays, scalars and
matrices are copied as before.

**S4: one `SecureChannelContext` per channel token.**
`TcpTransportListener.OnRequestReceived` created a context per request,
although it only holds values that stay the same for the life of a
token. The channel keeps the last context with the values it was created
from and returns it while they are the same references; a renewed token
(new thumbprint, rotated certificate) or a transport reconnected to the
channel gets a new one. The isolation provider (#4787) and the session
binding check compare contexts by value.

**S5: fewer bookkeeping objects per request.**
- A request inside its service dispatch scope, which is every request
the server dispatches, needs no `RequestExecutionScope`: its
`OperationContext` completes the request itself, exactly once, when it
is disposed.
- `FairRequestQueue.TryEnqueue` owns the unpublished entry in a local
instead of a `PendingAdmission` wrapper, and registers the entry's
cancellation with a static callback instead of a delegate per request.
- `DequeueAsync` pools its state machine on .NET.

**S6: fewer codec objects per message.** Encoding a message is
synchronous, so a thread encodes one message at a time. The decoder and
the encoder stay immutable: every message gets new ones (see the
review).
- `EncodeSymmetricMessage` writes the body to the `ArraySegmentStream`
of the thread, reset per message, through a new `BinaryEncoder` that
borrows the `BinaryWriter` the thread keeps over that stream (closing or
disposing such an encoder flushes the writer but leaves it and the
stream open). The stream is given back once it handed its buffers over;
a body that fails to encode releases the stream's buffers, and the
stream is not reused. The stream is per thread, not per channel: since
#4782 the producers encode the responses of a channel concurrently and
only sealing runs in the writer.
- A response (client) in one chunk is decoded from that chunk's buffer,
as the server decodes requests; the client used a stream over the chunk
list for every response.
- A `BinaryDecoder` over a buffer creates its `MemoryStream` and
`BinaryReader` only when `BaseStream` exposes the stream, and reports
`Position` without exposing it. `DecodeMessage` reads `Position`, which
exposed the stream for every message and made every later read
synchronize its position with it.
- `ArraySegmentStream.GetBuffers` hands over the stream's own collection
instead of copying it; a message in one chunk is reassembled into a
collection sized for one.

### Not done

- **Skipping the reset of a sent response** (the third
`ResponseHeader`): `LeasedPublishResponse` releases its notification
lease in `Reuse()`.
- **Pooling `RequestLifetime`, `OperationContext` and
`EndpointIncomingRequest`:** they leave the stack. `RequestLifetime` is
public and handed to every service handler, and its token to node
managers. `EndpointIncomingRequest` is handed to the overridable
`ScheduleIncomingRequest`.
- **Pooling the fair queue `Entry`:** its cancellation registration and
the stop path can still reach it after it was released.
- **A struct token for the isolation lease:** leases are returned as
`IDisposable` through `IServerResourceIsolationProvider.TryAcquire`.
- **Reading the permission defaults without a copy when roles are
configured:** not measurable with the o6 server, which configures none.

## Behaviour notes

- **S1:** an override of `ServerBase.CreateResponse(RequestHeader,
StringTable)` that calls the base gets the response's default header
instead of a new one, the header the response carried anyway.
- **S2:**
- The node managers receive the Read result lists as arrays (`IList<T>`,
fixed size). The lists were pre-filled per item; adding to them was
never part of the contract.
- `ReadValueId.Handle` of a Read request holds the passed-on
`NodeHandle` during the call.
- **S4:** the requests of one token share one `SecureChannelContext`.
`UpstreamIdentity` has a public setter; the TCP transport never sets it,
but application code that set it on the context of a TCP request would
now change it for the token's later requests as well.
- **S6:**
- Each thread that encodes channel messages keeps one stream and writer
(a few hundred bytes), with a reference to the buffer manager of the
last channel it served.
- `BinaryDecoder.Position` of a buffer decoder no longer exposes
`BaseStream`.

## Testing

- **New tests:**
- S1, `StandardServerResponseStringTableTests` and
`ReferenceServerTests`:
- the offered header is filled with every field (timestamp, request
handle, service result, no diagnostics, string table);
    - an override without the base keeps its own header;
    - an override with the base fills the offered one;
    - a re-entrant response never shares it;
    - Read, Write and Browse responses keep every header field;
    - a failed Read throws its status.
- S2, `AsyncCustomNodeManagerTests.ReadRouting` (all three node manager
fixtures):
- a single-node read uses the handle of the validation and returns what
a multi-node read returns;
- a handle passed on for an earlier read is not reused (another node,
removed node, no session);
- operation diagnostics keep one entry per node and are absent when not
requested.
- S3, `VariantCoverageTests`: empty arrays of each kind are shared; a
null array stays null; `TryGetStructure` of an empty, a null and a
one-element array.
- S4, `TcpListenerChannelTests`: the context is shared until the
thumbprint or the transport changes.
  - S5, `RequestManagerTests`:
- an attached request completes once, however often its context is
disposed;
    - the ambient request id is restored outside a dispatch scope;
- a stress test of attach, double dispose from two threads, cancellation
and drains.
  - S6:
- `BinaryEncoderTests`: encoders borrowing one writer encode like new
encoders, and leave the writer and the stream open; null arguments;
- `BinaryDecoderTests`: `BaseStream` and `Position` of a buffer decoder;
range checks;
- `ArraySegmentStreamTests`: `GetBuffers` hands over and the stream
continues; a read stream still returns a copy; `Reset` and
`ReleaseBuffers`.
- **Run locally (net10.0, all pass):** Opc.Ua.Types.Tests,
Opc.Ua.Core.Tests, Opc.Ua.Core.Encoders.Tests, Opc.Ua.Server.Tests,
Opc.Ua.Client.Tests, Opc.Ua.Sessions.Tests, Opc.Ua.Subscriptions.Tests,
Opc.Ua.Redundancy.Server.Tests, Opc.Ua.Redundancy.Client.Tests,
Opc.Ua.Stress.Tests Contract + Integration. net48 builds.
- **Measured with:** `perf/ServerLoadHarness` scenarios `o6read` (o6
harness, not part of this PR), `read`, `browse`, `sub`.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 11:25:56 +02:00
Marc Schier b3c1e60f46 AMB: add bounded strict reads and configurable sample hosting (#4803)
# Description

Extract the AMB layer from #4462 onto the client/server predecessor
#4802, then address the four review findings in
`6428686ae45724510bd6bd00619f4992f3d36e46`.

The initial extraction (`95b0caa1ac30d1fdacaf7a780f19c5f97e6fd02d`)
contained exactly 19 paths: 12 AMB client files, four AMB test files,
two sample entry points and `docs/AMB.md`. Its code/tests matched the
frozen source
`5b03e9afa492cdb36049ee52ae844f630f89d8db...390c4a7a51b71da40caec2d22ef74518bbe670ce`;
only the two agreed UaLens-only documentation passages were omitted. The
review follow-up intentionally changes that extracted implementation and
adds the related factory/registration tests and package documentation.
No UaLens, Machinery, Pumps, Positioning, native-rendering, build or CI
changes are introduced by this layer.

The AMB client provides opt-in bounded traversal, strict response/type
validation and original `AssetReadValue` evidence while preserving
tolerant defaults. The samples expose validated host, port, PKI root,
certificate auto-accept and bounded-runtime options, with unchanged
standalone defaults.

The review corrections are:

- Accept header-validated empty BrowseNext release responses required by
Part 4 §5.9.3.2, as well as the stack's single-result response. Invalid
cleanup results still preserve the original failure.
- Keep event observation tolerant of denied/limited maintenance-class
discovery without caching incomplete classes. Subsequent strict reads
retry discovery and still report errors.
- Accept typed-null ExtensionObjects only in the nullable
structure-field checks, preserving original evidence without weakening
scalar validation.
- Pass optional immutable limits through `AddAssetManagementClient`, the
registered factory/delegate and directly constructed factories. Preserve
existing overloads and tolerant behavior when options are omitted;
reject invalid limits before opening a session.

### Review regression evidence

| Requirement | Evidence |
| --- | --- |
| Accept empty cleanup responses without masking cancellation or limits
| `OneBeyondReferenceLimitReleasesAcquiredContinuationAsync` and
`CancellationWhileAwaitingNextReleasesTheSubmittedContinuationAsync`,
each with empty and single-result responses; malformed responses remain
covered |
| Preserve observation fallback and do not cache incomplete classes |
`ObservationFallsBackWithoutCachingIncompleteClassesAsync`: both
observation APIs, denied browsing and exhausted budget, correct
filter/event evidence, and a subsequent strict rediscovery |
| Preserve typed-null structure evidence without weakening scalar checks
| `ContextAndEntryReadsKeepSourceAndNullStructureEvidenceAsync`,
`MaintenanceTypedNullStructuresKeepOriginalEvidenceAsync`,
`ScalarHealthRejectsTypedNullStructuresAsync`, plus
wrong-non-null-structure rejection tests |
| Apply configured hosted limits while retaining tolerant defaults |
`HostedClientOptionsReachClientsAsync`: registered factory, registered
delegate and direct factory, singleton and builder registration, actual
one-reference limit and read evidence;
`InvalidHostedLimitsAreRejectedBeforeOpeningASession` |

### Validation

All commands below ran in this layer's worktree with the existing pinned
.NET SDK 10.0.401. No SDK installation, global.json change,
registry/path-policy change, test suppression or analyzer suppression
was made. The short owned NUnit directory `D:\a61r` and its generated
certificates/state were removed afterward.

**Latest full AMB suite: Passed: 285, Failed: 0, Skipped: 0 on each
framework.** Both builds emitted no compiler/analyzer diagnostics. TRX
files confirm the regression cases executed on both TFMs.

```powershell
& 'D:\git\UA-.NETStandard_master\TestResults\ualens-528-deep-repro\sdk\dotnet.exe' test 'tests\Opc.Ua.AMB.Tests\Opc.Ua.AMB.Tests.csproj' --no-restore --configuration Release --framework net10.0 -p:CustomTestTarget=net10.0 --nologo --verbosity minimal --logger 'trx;LogFileName=amb-feedback-net10.trx' --results-directory 'C:\Users\mschier\.copilot\session-state\61d7ed73-c89e-406a-a049-1d8ba9681308\files\TestResults\feedback-net10' -- 'NUnit.WorkDirectory=D:\a61r'
& 'D:\git\UA-.NETStandard_master\TestResults\ualens-528-deep-repro\sdk\dotnet.exe' test 'tests\Opc.Ua.AMB.Tests\Opc.Ua.AMB.Tests.csproj' --configuration Release --framework net48 -p:CustomTestTarget=net48 --nologo --verbosity minimal --logger 'trx;LogFileName=amb-feedback-net48.trx' --results-directory 'C:\Users\mschier\.copilot\session-state\61d7ed73-c89e-406a-a049-1d8ba9681308\files\TestResults\feedback-net48' -- 'NUnit.WorkDirectory=D:\a61r'
```

**Focused regression run: Passed: 127, Failed: 0, Skipped: 0.** Before
the production fixes, the focused repro executed 112 cases with 16
failures reproducing all four reported defects. Additional
registration/validation cases were included in the final run.

```powershell
& 'D:\git\UA-.NETStandard_master\TestResults\ualens-528-deep-repro\sdk\dotnet.exe' test 'tests\Opc.Ua.AMB.Tests\Opc.Ua.AMB.Tests.csproj' --no-restore --configuration Release --framework net10.0 -p:CustomTestTarget=net10.0 --nologo --verbosity minimal --filter 'FullyQualifiedName~AmbBoundedClientTests|FullyQualifiedName~AmbClientTests|FullyQualifiedName~HostedClientOptionsReachClientsAsync' --logger 'trx;LogFileName=amb-feedback-focused.trx' --results-directory 'C:\Users\mschier\.copilot\session-state\61d7ed73-c89e-406a-a049-1d8ba9681308\files\TestResults\feedback-focused' -- 'NUnit.WorkDirectory=D:\a61r'
```

**Whitespace and newly introduced style findings: passed.** `git diff
--check` and the following commands exited 0:

```powershell
& 'D:\git\UA-.NETStandard_master\TestResults\ualens-528-deep-repro\sdk\dotnet.exe' format whitespace --folder --verify-no-changes --verbosity quiet --include 'src\Opc.Ua.AMB.Client\AmbClient.Bounds.cs' 'src\Opc.Ua.AMB.Client\AmbClient.Health.cs' 'src\Opc.Ua.AMB.Client\AmbClient.Structure.cs' 'src\Opc.Ua.AMB.Client\AmbClient.Values.cs' 'src\Opc.Ua.AMB.Client\AmbClientFactory.cs' 'src\Opc.Ua.AMB.Client\Hosting\OpcUaAmbClientBuilderExtensions.cs' 'tests\Opc.Ua.AMB.Tests\AmbBoundedClientTests.cs' 'tests\Opc.Ua.AMB.Tests\AmbBoundedClientTests.Traversal.cs' 'tests\Opc.Ua.AMB.Tests\AmbBoundedClientTests.Values.cs' 'tests\Opc.Ua.AMB.Tests\AmbClientServerE2eTests.cs' 'tests\Opc.Ua.AMB.Tests\AmbClientTests.cs'
$env:CustomTestTarget = 'net10.0'
$env:Configuration = 'Release'
& 'D:\git\UA-.NETStandard_master\TestResults\ualens-528-deep-repro\sdk\dotnet.exe' format style 'tests\Opc.Ua.AMB.Tests\Opc.Ua.AMB.Tests.csproj' --no-restore --verify-no-changes --severity info --diagnostics IDE0039 --verbosity quiet --include 'tests\Opc.Ua.AMB.Tests\AmbClientServerE2eTests.cs' 'tests\Opc.Ua.AMB.Tests\AmbClientTests.cs'
```

The broader formatter check was **not clean**. It reported pre-existing
informational suggestions and design-time `NUnit1003` diagnostics for
generated browse-name constants, including pre-existing test cases.
Those diagnostics do not occur in the actual builds, which compile and
execute those cases successfully. Two new local-function suggestions
were corrected and rechecked above; no unrelated baseline cleanup or
suppression was included.

```powershell
$env:CustomTestTarget = 'net10.0'
& 'D:\git\UA-.NETStandard_master\TestResults\ualens-528-deep-repro\sdk\dotnet.exe' format 'tests\Opc.Ua.AMB.Tests\Opc.Ua.AMB.Tests.csproj' --no-restore --verify-no-changes --severity info --verbosity quiet --include 'src\Opc.Ua.AMB.Client\AmbClient.Bounds.cs' 'src\Opc.Ua.AMB.Client\AmbClient.Health.cs' 'src\Opc.Ua.AMB.Client\AmbClient.Structure.cs' 'src\Opc.Ua.AMB.Client\AmbClient.Values.cs' 'src\Opc.Ua.AMB.Client\AmbClientFactory.cs' 'src\Opc.Ua.AMB.Client\Hosting\OpcUaAmbClientBuilderExtensions.cs' 'tests\Opc.Ua.AMB.Tests\AmbBoundedClientTests.cs' 'tests\Opc.Ua.AMB.Tests\AmbBoundedClientTests.Traversal.cs' 'tests\Opc.Ua.AMB.Tests\AmbBoundedClientTests.Values.cs' 'tests\Opc.Ua.AMB.Tests\AmbClientServerE2eTests.cs' 'tests\Opc.Ua.AMB.Tests\AmbClientTests.cs'
```

<details>
<summary>Initial extraction validation and environment history</summary>

The original extraction passed all 263 AMB tests on both TFMs. Its
initial net48 run had one unchanged maintenance-test server-start
timeout; shortening only NUnit.WorkDirectory resolved it, consistent
with certificate-store path lengths. This follow-up used a short
directory from the start and all 285 cases passed on both frameworks.

A no-restore test invocation during the follow-up initially executed no
tests; it was not counted as passing evidence. A no-restore build
identified `NETSDK1005` (missing net10.0 target assets after switching
TFMs). The normal test invocation restored the target assets. Initial
regression-test compilation issues were corrected before capturing the
16 failing behavioral cases.

Both samples were built during extraction on both TFMs; each command
below passed with 0 warnings and 0 errors. They were not rebuilt during
this review follow-up, which does not modify the samples.

```powershell
& 'D:\git\UA-.NETStandard_master\TestResults\ualens-528-deep-repro\sdk\dotnet.exe' build 'samples\AMB\AmbClient\AmbClient.csproj' --configuration Release --framework net10.0 -p:CustomTestTarget=net10.0 --nologo --verbosity quiet
& 'D:\git\UA-.NETStandard_master\TestResults\ualens-528-deep-repro\sdk\dotnet.exe' build 'samples\AMB\AmbServer\AmbServer.csproj' --configuration Release --framework net10.0 -p:CustomTestTarget=net10.0 --nologo --verbosity quiet
& 'D:\git\UA-.NETStandard_master\TestResults\ualens-528-deep-repro\sdk\dotnet.exe' build 'samples\AMB\AmbClient\AmbClient.csproj' --configuration Release --framework net48 -p:CustomTestTarget=net48 --nologo --verbosity quiet
& 'D:\git\UA-.NETStandard_master\TestResults\ualens-528-deep-repro\sdk\dotnet.exe' build 'samples\AMB\AmbServer\AmbServer.csproj' --configuration Release --framework net48 -p:CustomTestTarget=net48 --nologo --verbosity quiet
```

</details>

This remains a draft: the complete `UA.slnx` net48/net10.0 matrix,
coverage, CI/CodeQL and CLA status are not established by this focused
validation. No coverage increase or complete-solution pass is claimed.
The feedback commit is based on the original AMB extraction; integration
of later lower-layer updates is coordinated separately, without
rewriting this branch or native stack 4807.

## Related Issues

- References #4461, the existing tracking issue; this PR does not close
it.
- Extracted from #4462 at frozen head
`390c4a7a51b71da40caec2d22ef74518bbe670ce`; the source PR is not closed
by this PR.
- Immediate predecessor: #4802 (`marcschier-extract-client-and-server`),
above core/types #4801. The original extraction parent was
`86ff10d3994f132f7343a929abdae209bc2e69b7`.

This is an isolated extraction and review correction of the existing
tracked implementation, not a claim of new architectural sign-off.

## 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.
2026-10-15 21:08:24 +02:00
Marc Schier 1f6700c30b Fail closed when reconnect cannot preserve endpoint security (#4809)
# Description

It was reported that endpoint refresh during reconnect
(`UpdateBeforeConnect` / reconnect endpoint update) could silently
change a connection's security configuration (e.g. from `SignAndEncrypt`
to a different mode/policy) without the application knowing.

Investigation showed the hard downgrade paths had already been fixed,
but the legacy `SessionReconnectHandler` still had a composite-score
fallback that could select a *different* security mode/policy when the
original combination was no longer offered by the (unauthenticated)
discovery reply. A stronger mode could offset a weaker or deprecated
policy in that score. Managed failover/alternate-network endpoints also
had no explicit mode/policy guard.

Changes:
- **`SessionReconnectHandler`**: removed `SelectFallbackEndpoint` and
the alternate-policy fallback. If the exact mode/policy is unavailable,
reconnect fails (logged as an error) instead of changing security.
- **`ManagedSession`**: added a mode/policy mismatch check; mismatched
alternate-network endpoints, reactivation targets and
failover/recreation targets are rejected with `BadSecurityModeRejected`
/ `BadSecurityPolicyRejected` before a channel is opened.
- Endpoint refresh for **certificate rotation is retained**; new server
certificates are still subject to normal trust validation.
- **Docs** (`docs/Sessions.md`): documents exact mode/policy
preservation during recovery and that `UpdateBeforeConnect` does not
authorize security negotiation. Changing policy requires explicit
reconfiguration and a new connection.
- **Tests**: replaced fallback-selection tests with a fail-closed legacy
reconnect test; added managed alternate-endpoint policy mismatch and
failover mode mismatch tests.

Validation:
- `dotnet test tests/Opc.Ua.Client.Tests/Opc.Ua.Client.Tests.csproj -f
net10.0 --filter
"FullyQualifiedName~SessionReconnectHandlerTests|FullyQualifiedName~ManagedSessionTests"`
— passed (48/48)
- `dotnet test tests/Opc.Ua.Core.Tests/Opc.Ua.Core.Tests.csproj -f
net10.0 --filter "FullyQualifiedName~ConfiguredEndpointTests"` — passed
(46/46)
- Full UA.slnx suite and net48 tests: not run locally.

## Related Issues

- Reported security concern: reconnect endpoint refresh could change the
session's security configuration without notifying the application.

## Checklist

- [ ] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [x] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [x] I have added all necessary documentation.
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed.
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [ ] I have addressed **all** PR feedback received.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-15 20:33:35 +02:00
Wolfgang Colsman 1b7db89f26 PKCS#11 store: write and delete certificate objects on the token (#4792)
# Description

Pkcs11CertificateStore.AddAsync returned success without writing
anything, so a certificate added to a token backed store (a trust list
update, a renewed application certificate) was silently lost.
DeleteAsync always threw NotSupportedException.

AddAsync now writes the certificate as an X.509 certificate object with
CKA_VALUE, CKA_SUBJECT, CKA_ISSUER, CKA_SERIAL_NUMBER, CKA_LABEL and a
CKA_ID that links it to the key it belongs to: the id the store's object
filter selects, else the id of a certificate on the token with the same
public key, else the id of the key whose modulus (RSA) or EC point
matches, else the SHA-1 hash of the public key. A renewed certificate
for
a key generated on the token is therefore paired with that key by
LoadPrivateKeyAsync. A certificate the token already holds is not
written
again. A private key offered with the certificate is still never written
to the token; it is counted in RejectedPrivateKeyWrites as before.

DeleteAsync destroys the certificate objects with the thumbprint and
returns whether there were any. Key objects are never destroyed.

The PKCS#11 test double now supports C_CreateObject and C_DestroyObject.
Tests cover both operations against it and, in the token backed fixture,
against SoftHSM2. The two tests that pinned the old behaviour (a dropped
write, a delete that throws) are removed.

## Related Issues

None.

## Checklist

- [x] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [x] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [x] I have added all necessary documentation.
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [x] 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.
- [ ] I have addressed **all** PR feedback received.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Marc Schier <marcschier@users.noreply.github.com>
2026-10-15 20:03:25 +02:00
Marc Schier e78d36ce4b Client/server transfer, WSS, and hosting fixes (#4802)
# Description

Layer 2 of the upstream non-UaLens extraction, with core/types
prerequisite #4801 (`marcschier-extract-core-and-types`). The initial
extraction commit `86ff10d3994f132f7343a929abdae209bc2e69b7` started
from `8881e096fb5e4eaa36f3c13f8caa57733373bd19` and copied exactly 13
client/server paths from #4462 at frozen head
`390c4a7a51b71da40caec2d22ef74518bbe670ce`, using its original base
`5b03e9afa492cdb36049ee52ae844f630f89d8db`. Those initial blobs were
source-identical; the core/types layer was not duplicated or rewritten.

- Add bounded temporary-file copying with byte/request limits, session
ownership checks, and independent bounded cleanup.
- Refresh revised monitored-item parameters after snapshot transfer and
notify the target handler after successful handle synchronization.
- Apply WSS opening deadlines while preserving caller cancellation, and
cover the wire envelope and request timeout behavior.
- Make hosted server presets honor role configuration without replacing
an explicitly registered role manager; include the corresponding tests
and documentation.

**Review follow-up:** commit `a5c8fe0884ad3346106529deba71d93c5df20a9a`
intentionally corrects three defects in the extracted bounded-copy
implementation. It captures one `SessionRequestContext` for managed
transfers, calls the standard `FileType.Close` declaration without
metadata discovery, and retains valid generation handles before
rejecting non-Good status or malformed metadata. Read/Close remain
strictly validated, and transfer/cleanup failures remain independently
visible. The follow-up also adds 21 direct-client regression cases and
updates `docs/FileSystemClient.md`, bringing this PR to 15 changed
paths. The current bounded-copy implementation is therefore
intentionally no longer identical to the frozen source.

No UaLens projects/UI, AMB or Machinery implementations, samples,
CI/native renderer changes, release changes, or generic infrastructure
files are included. The follow-up does not merge/rebase a lower or upper
stack layer.

**Current review-fix validation:** normal analyzers and
warnings-as-errors remained enabled, using the existing SDK **10.0.401**
without changing `global.json`, installing an SDK, or adding
suppressions. The final file-system and session-context selection passed
**241 tests on net10.0 and 241 tests on net48**, with **0 failed and 0
skipped** on each. Both selections include all 21 new regressions.
Before the production fix, the first 20-case regression suite had 13
failures and 7 passes, reproducing the lost cleanup, cancellation,
session-recreation and Close-discovery defects. An additional
deterministic recovery-gate race case was then added and passed on both
frameworks. Informational-severity formatting/style/analyzer checks
passed for the changed C# files.

| Review requirement | Direct regression evidence |
| --- | --- |
| Pin the managed generation through copying and cleanup |
`CopyRejectsRecreatedManagedGenerationAsync`,
`CopyRejectsRecoveryBetweenGenerationAndReadDispatchAsync` |
| Preserve a generation response that returns after cancellation |
`ManagedCopyClosesHandleReturnedAfterCancellationAsync` with immediate
and delayed responses through a real managed transport lease |
| Do not spend the Close deadline discovering metadata |
`CopyUsesDeclarationCloseWithoutDiscoveryAsync` for ordinary and managed
sessions |
| Retain valid handles before rejecting generation status/metadata |
`CopyClosesHandleBeforeRejectingGenerationStatusAsync`,
`CopyClosesHandleFromMalformedGenerationResponseAsync` |
| Preserve error semantics and strict Read/Close validation |
`CopyRejectsGenerationWithoutInventingHandleAsync`,
`CopyPreservesGenerationAndCleanupFailuresAsync`,
`CopyKeepsReadAndCloseStatusValidationAsync` |

<details>
<summary>Exact review-fix validation commands and results</summary>

Commands ran from this layer's repository root. The following variables
expand the actual executable, filters and results paths. Console logs
and TRX reports were retained in the session artifacts.

```powershell
$dotnet = 'D:\git\UA-.NETStandard_master\TestResults\ualens-528-deep-repro\sdk\dotnet.exe'
$env:DOTNET_ROOT = 'D:\git\UA-.NETStandard_master\TestResults\ualens-528-deep-repro\sdk'
$env:DOTNET_ROOT_X64 = $env:DOTNET_ROOT
$env:DOTNET_CLI_TELEMETRY_OPTOUT = '1'
$client = 'tests\Opc.Ua.Client.Tests\Opc.Ua.Client.Tests.csproj'
$filter = 'FullyQualifiedName~Opc.Ua.Client.Tests.FileSystem|FullyQualifiedName~Opc.Ua.Client.Tests.SessionContexts'
$r10 = 'C:\Users\mschier\.copilot\session-state\9b51b89f-b5ed-4ed7-a097-cca67efe7b0e\files\feedback-net10'
$r48 = 'C:\Users\mschier\.copilot\session-state\9b51b89f-b5ed-4ed7-a097-cca67efe7b0e\files\feedback-net48'

# Before modifying production code, after the new tests compiled:
& $dotnet test $client --no-restore -c Release -f net10.0 -p:CustomTestTarget=net10.0 --filter 'FullyQualifiedName~TemporaryFileTransferClientBoundedReadTests' --logger 'trx;LogFileName=bounded-copy-feedback-red.trx' --results-directory $r10 --verbosity quiet
# Expected regression failure: Passed: 7, Failed: 13, Skipped: 0.

# After the production fix, before adding the explicit queued-recovery case:
& $dotnet test $client --no-restore -c Release -f net10.0 -p:CustomTestTarget=net10.0 --filter 'FullyQualifiedName~TemporaryFileTransferClientBoundedReadTests' --logger 'trx;LogFileName=bounded-copy-feedback-green.trx' --results-directory $r10 --verbosity quiet
# Passed: 20, Failed: 0, Skipped: 0.

# Final complete affected selections, including all 21 new cases:
& $dotnet test $client --no-restore -c Release -f net48 -p:CustomTestTarget=net48 --filter $filter --logger 'trx;LogFileName=file-system-context-feedback-final.trx' --results-directory $r48 --verbosity quiet
# Passed: 241, Failed: 0, Skipped: 0.

& $dotnet test $client -c Release -f net10.0 -p:CustomTestTarget=net10.0 --filter $filter --logger 'trx;LogFileName=file-system-context-feedback-final.trx' --results-directory $r10 --verbosity quiet
# Passed: 241, Failed: 0, Skipped: 0.

$sourceFile = 'D:\git\copilot-worktrees\UA-wot-d3-final\marcschier-improved-invention\src\Opc.Ua.Client\FileSystem\TemporaryFileTransferClient.BoundedRead.cs'
$testFile = 'D:\git\copilot-worktrees\UA-wot-d3-final\marcschier-improved-invention\tests\Opc.Ua.Client.Tests\FileSystem\TemporaryFileTransferClientBoundedReadTests.cs'
$env:CustomTestTarget = 'net48'
& $dotnet format 'src\Opc.Ua.Client\Opc.Ua.Client.csproj' --include $sourceFile --no-restore --verify-no-changes --severity info --verbosity quiet
# Passed, exit 0.
$env:CustomTestTarget = 'net10.0'
& $dotnet format $client --include $testFile --no-restore --verify-no-changes --severity info --verbosity quiet
# Passed, exit 0.

git diff --check
# Passed. CRLF, final newlines and the 120-column C# limit were also checked.
```

Initial no-restore test attempts after switching frameworks exited
without executing tests and were not counted as passes. Diagnostic
`dotnet build ... --no-restore` runs reported `NETSDK1005` for the
missing framework assets; normal restore was then allowed.
Test-authoring compile errors were corrected before recording the red
run, without suppressions. Intermediate broader selections passed
240/240 on each framework before the queued-recovery case increased the
final count to 241.

</details>

**Initial-extraction validation (commit
`86ff10d3994f132f7343a929abdae209bc2e69b7`):** these results are
historical, not a claim that the broader initial selections were rerun
after the review fix. The server test assembly was built as a dependency
of the client project before its `--no-build` runs.

| Initial targeted scope | net10.0 | net48 |
| --- | --- | --- |
| Client subscriptions and file system | Passed: 546, Failed: 0,
Skipped: 0 | Passed: 542, Failed: 0, Skipped: 0 |
| Hosted roles, identity, composition, fluent APIs and builder coverage
| Passed: 164, Failed: 0, Skipped: 0 | Passed: 164, Failed: 0, Skipped:
0 |
| WebAPI WSS transport | Passed: 37, Failed: 0, Skipped: 0 | Not
supported: the repository excludes this .NET 8+ test project on net48 |
| V2 transfer and snapshot integration | Passed: 6, Failed: 0, Skipped:
0 | Passed: 6, Failed: 0, Skipped: 0 |

The first broader net48 hosting run had **9 startup timeouts and
certificate leaks** (155 passed). A single startup case reproduced the
failure. With only a shorter `NUnit.WorkDirectory`, the isolated case
and then all 164 hosting cases passed, with certificate leak tracking
enabled and no teardown leaks. This is consistent with the fixture's
documented .NET Framework PKI path-length constraint; no source or
diagnostic suppression was changed. Both failed runs and successful
reruns are recorded below.

**Deferred coverage:** the broader domain-level
`MachineryResultTransferTests*.cs` fixtures remain in Machinery layer
#4804. They were not imported or run here. This review follow-up adds
direct-client coverage for the three defects instead. The Machinery
owner has been informed that the old expectations for failed Close
discovery and zero cleanup after a returned non-Good generation response
must be reconciled when the lower fixes are propagated.

**Not run:** complete `UA.slnx` suites on net48/net10.0, a coverage
measurement, CI/CodeQL completion, native/fuzz qualifications, or
package publishing. This remains a draft pending full-stack integration
and CI evidence.

<details>
<summary>Initial-extraction validation command arguments and
outcomes</summary>

All commands ran from this layer's repository root. Variables below
expand the executable, project paths, filters and results directories
used by the actual commands; test output was also retained in
session-local log files.

```powershell
$dotnet = 'D:\git\UA-.NETStandard_master\TestResults\ualens-528-deep-repro\sdk\dotnet.exe'
$env:DOTNET_ROOT = 'D:\git\UA-.NETStandard_master\TestResults\ualens-528-deep-repro\sdk'
$env:DOTNET_ROOT_X64 = $env:DOTNET_ROOT
$env:DOTNET_CLI_TELEMETRY_OPTOUT = '1'
$r10 = 'C:\Users\mschier\.copilot\session-state\9b51b89f-b5ed-4ed7-a097-cca67efe7b0e\files\validation-net10'
$r48 = 'C:\Users\mschier\.copilot\session-state\9b51b89f-b5ed-4ed7-a097-cca67efe7b0e\files\validation-net48'
$client = 'tests\Opc.Ua.Client.Tests\Opc.Ua.Client.Tests.csproj'
$server = 'tests\Opc.Ua.Server.Tests\Opc.Ua.Server.Tests.csproj'
$webapi = 'tests\Opc.Ua.Bindings.Https.WebApi.Tests\Opc.Ua.Bindings.Https.WebApi.Tests.csproj'
$subscriptions = 'tests\Opc.Ua.Subscriptions.Tests\Opc.Ua.Subscriptions.Tests.csproj'
$clientFilter = 'FullyQualifiedName~Opc.Ua.Client.Subscriptions|FullyQualifiedName~Opc.Ua.Client.Tests.FileSystem'
$serverFilter = 'FullyQualifiedName~HostedRolePresetTests|FullyQualifiedName~IdentityHostingTests|FullyQualifiedName~ServerFluentApiHostingTests|FullyQualifiedName~ServerCompositionHostingTests|FullyQualifiedName~OpcUaServerBuilderExtensionsCoverageTests'
$subscriptionFilter = 'FullyQualifiedName~Opc.Ua.Subscriptions.Tests.TransferSubscriptionTests|FullyQualifiedName~Opc.Ua.Subscriptions.Tests.SubscriptionSnapshotTests'

& $dotnet --version
# Passed: 10.0.401.

& $dotnet test $client --no-restore -c Release -f net10.0 -p:CustomTestTarget=net10.0 --filter $clientFilter --logger 'trx;LogFileName=client-net10.trx' --results-directory $r10 --verbosity quiet
# Exit 0 but no tests executed: missing restore metadata. NOT counted as validation.

& $dotnet build $client --no-restore -c Release -f net10.0 -p:CustomTestTarget=net10.0 --verbosity quiet
# Failed: NETSDK1004, missing obj\project.assets.json. Normal restore was then allowed in test commands.

& $dotnet test $client -c Release -f net10.0 -p:CustomTestTarget=net10.0 --filter $clientFilter --logger 'trx;LogFileName=client-net10.trx' --results-directory $r10 --verbosity quiet
# Passed: 546, Failed: 0, Skipped: 0.

& $dotnet test $server --no-build --no-restore -c Release -f net10.0 -p:CustomTestTarget=net10.0 --filter $serverFilter --logger 'trx;LogFileName=server-net10.trx' --results-directory $r10 --verbosity quiet
# Passed: 164, Failed: 0, Skipped: 0.

& $dotnet test $webapi -c Release -f net10.0 -p:CustomTestTarget=net10.0 --filter 'FullyQualifiedName~WebApiWssTransportChannelTests' --logger 'trx;LogFileName=webapi-net10.trx' --results-directory $r10 --verbosity quiet
# Passed: 37, Failed: 0, Skipped: 0.

& $dotnet test $client -c Release -f net48 -p:CustomTestTarget=net48 --filter $clientFilter --logger 'trx;LogFileName=client-net48.trx' --results-directory $r48 --verbosity quiet
# Passed: 542, Failed: 0, Skipped: 0.

& $dotnet test $server --no-build --no-restore -c Release -f net48 -p:CustomTestTarget=net48 --filter $serverFilter --logger 'trx;LogFileName=server-net48.trx' --results-directory $r48 --verbosity quiet
# Failed: Passed: 155, Failed: 9, Skipped: 0; startup timeouts and 9 undisposed certificates at teardown.

& $dotnet test $subscriptions -c Release -f net48 -p:CustomTestTarget=net48 --filter $subscriptionFilter --logger 'trx;LogFileName=subscriptions-net48.trx' --results-directory $r48 --verbosity quiet
# Passed: 6, Failed: 0, Skipped: 0.

& $dotnet test $subscriptions -c Release -f net10.0 -p:CustomTestTarget=net10.0 --filter $subscriptionFilter --logger 'trx;LogFileName=subscriptions-net10.trx' --results-directory $r10 --verbosity quiet
# Passed: 6, Failed: 0, Skipped: 0.

& $dotnet msbuild $webapi -p:CustomTestTarget=net48 -p:TargetFramework=net48 -getProperty:TargetFramework,RestrictForLegacyTfm,_RestrictedToLegacyTfm,IsTestProject,EnableDefaultCompileItems
# Passed: net48 / true / true / false / false. Confirms the net48 project is an empty non-test shell.

# Each following diagnostic invocation had certificate allocation tracking enabled.
$env:OPCUA_CERTIFICATE_LEAK_TRACKING = '1'

& $dotnet test $server --no-build --no-restore -c Release -f net48 -p:CustomTestTarget=net48 --filter 'FullyQualifiedName~HostedRolePresetTests' --logger 'trx;LogFileName=hosted-role-net48.trx' --results-directory $r48 --verbosity quiet
# Passed: 10, Failed: 0, Skipped: 0.

& $dotnet test $server --no-build --no-restore -c Release -f net48 -p:CustomTestTarget=net48 --filter 'FullyQualifiedName~ServerFluentApiHostingTests.AddServerWithCustomServerStartsCustomServerAsync' --logger 'trx;LogFileName=hosting-startup-net48.trx' --results-directory $r48 --verbosity quiet
# Failed: Passed: 0, Failed: 1, Skipped: 0; 30-second startup timeout and one certificate leak.

# A fresh, dedicated short working directory was created for the following two runs.
& $dotnet test $server --no-build --no-restore -c Release -f net48 -p:CustomTestTarget=net48 --filter 'FullyQualifiedName~ServerFluentApiHostingTests.AddServerWithCustomServerStartsCustomServerAsync' --logger 'trx;LogFileName=hosting-startup-net48-shortpath.trx' --results-directory $r48 --verbosity quiet -- 'NUnit.WorkDirectory=D:\Temp\opc-l2-6caf4556'
# Passed: 1, Failed: 0, Skipped: 0; no certificate leak.

& $dotnet test $server --no-build --no-restore -c Release -f net48 -p:CustomTestTarget=net48 --filter $serverFilter --logger 'trx;LogFileName=server-net48-shortpath.trx' --results-directory $r48 --verbosity quiet -- 'NUnit.WorkDirectory=D:\Temp\opc-l2-6caf4556'
# Passed: 164, Failed: 0, Skipped: 0; no certificate leak or assembly-level teardown error.
# The dedicated short working directory and its generated PKI contents were removed afterward.
```

TRX inspection confirmed all initially added client (3), WSS (4), and
hosted-role (10) regression cases executed on supported frameworks. The
initial 13-path set and source blobs were verified before the extraction
commit. A separate mistyped SDK availability probe
(`uals-528-deep-repro` instead of `ualens-528-deep-repro`) failed before
launching any process; all actual builds/tests used the correct verified
SDK above.

</details>

## Related Issues

Related to #4461; extracted from source PR #4462. Neither is closed by
this PR. Prerequisite: #4801, the core/types layer.

_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))._

## 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.
2026-10-15 19:29:42 +02:00
Marc Schier 0374e19121 Add MachineTool, WMTP, GMS and CranesHoists companions (#4765)
# Description

_Describe the changes here to communicate to the maintainers why they
should accept this pull request. By default - this will become the
Commit message after merging and thus define history._

Add provider-backed MachineTool, bundled WMTP, GMS and CranesHoists
companions using the existing Machinery/MachineVision fluent, DI and
typed-client approach. **The PR is open/non-draft by user choice.
Current head `ecc4a7d6ca3e8004cf28ab54da605da302d026bf` is mergeable and
has 108 successful checks, six intentionally skipped checks, zero
failures and zero pending checks. All five primary workflows completed
successfully at that exact head.**

| Family | Released specification/model | Packaging |
| --- | --- | --- |
| MachineTool | OPC 40501-1, 1.02.0 | Model, Server, Client and MCP;
normal root versions |
| WMTP | OPC 40505, 1.0.0 | Bundled in MachineTool; distinct CLR/wire
namespace and independent WMTP-only hosting |
| GMS | OPC 40210 reference 1.0.1, normative model 1.0.0 | Model,
Server, Client and MCP; normal root versions |
| CranesHoists | OPC 40020-1, 1.00 | Model, Server, Client and MCP; all
four plus Debug variants forced preview |

There are **12 new packable projects**. No separate WMTP
package/profile/guide, root version bump, tag, release or package
publication is included. Mandatory instance structure is the default;
selected optional features require explicit providers. The
implementation reuses Machinery results/jobs/files/ProcessValues and
Robotics topology, including retained MachineTool Production, defined
GMS jobs/parts, modern ISA95/Machinery jobs, shared-owner hosting and
isolated instances alongside MachineVision.

Runtime and integration fixes preserve raw quality/timestamps, coherent
acquisition, namespace ownership, accepted-configuration state,
borrowed-resource lifetime, session/node/file cleanup, terminal-value
publication, event propagation and endpoint-aware capability
publication. Firmware/licence transfer means provider acceptance, not
installation/activation. Cancellation does not silently abort another
caller's operation. The later upstream MCP expansion is integrated
without losing either family set or its default-off privileged/live
execution policy.

### Conformance and scope

Canonical model inputs are pinned to UA-Nodeset revision
`96a08945425d0295edb3607d2a38f6841ef44046`.

**GMS upstream exception:** [Annex
A.1](https://reference.opcfoundation.org/specs/OPC-40210/annex-a) makes
the released NodeSet normative, but §12.3.2.7 requires undefined
`GMS_ProductionStatisticsType`. Inherited statistics and defined
jobs/parts are implemented without inventing a standard type/NodeId; the
complete GMS Production facet is withheld.

The requirement ledger records **102 positively evidenced CU rows, 96
directly negative-tested rows and six explicit negative-evidence
limitations**. All 648 test-class/name/TRX links were independently
resolved to passing cases. The six limitations concern Stacklight,
retained legacy PowerOnDuration, Machinery PowerOnDuration, WorkingUnit,
Obligation and MaintenanceMode. They are not claims that every
invalid-input or hardware-control behavior was tested. The undefined GMS
requirement is outside the 102 defined CUs. No external certification
claim is made.

Retained baseline complete-assembly line coverage, with generated code
retained and no source exclusions:

| Family | Model | Server | Client |
| --- | ---: | ---: | ---: |
| MachineTool/WMTP | 83.80% | 91.98% | 84.74% |
| GMS | 95.51% | 90.63% | 88.38% |
| CranesHoists | 92.76% | 94.45% | 92.24% |

These nine measurements predate the later focused review fixes and
merges; they are not relabelled as new `ecc4a7d6c` percentages. The
current head's separate CI code-coverage check succeeded. No new
exclusions or threshold relaxations were introduced.

## Related Issues

- Fixes
[#4764](https://github.com/OPCFoundation/UA-.NETStandard/issues/4764),
the implementation/design and acceptance tracker.

## 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.
- [x] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [x] I have added all necessary documentation.
- [ ] 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.
2026-10-15 15:15:46 +02:00
romanett 5bd3e116e1 Server request path: no large struct copies, slim per-request system context, no string table per request (R2, R5) (#4805)
## Description

Two changes to the per-request server path from the o6 benchmark roadmap
(R2, R5), found in the CPU and allocation profiles of the o6 read
benchmark (`AsyncCustomNodeManager`, Server GC, 10 clients × 100 Reads
in flight) on master 5b03e9afa (after #4767 and #4782). Each change is
its own commit. The request queue and the dispatch hop are not touched.

### Results

Paired A/B runs against master (5b03e9afa), alternating builds per
round, 5 rounds, medians (min..max). The machine is shared with other
sessions: allocated bytes per request are deterministic; CPU per request
is best read from the runs pinned to 4 cores. Each item was measured on
its own on master.

| Scenario | master | R2 | R5 |
|---|---|---|---|
| o6read 10 × 100, pinned to 4 cores: allocated / request | 4,702 B |
4,698 B | **4,358 B (−7.3 %)** |
| same: CPU / request | 31.8 µs (30.6..34.4) | 31.4 µs (29.3..33.1) |
32.1 µs (28.7..39.4) |
| same, `CustomNodeManager2`: allocated / request | 5,986 B | 5,980 B |
**5,532 B (−7.6 %)** |
| same: CPU / request | 37.7 µs | 36.2 µs | 37.1 µs |
| o6read encrypted, pinned, async NM: allocated / request | 4,745 B |
4,748 B | 4,397 B (−7.3 %) |
| o6read encrypted, pinned, `CustomNodeManager2`: allocated / request |
6,036 B | 6,037 B | 5,581 B (−7.5 %) |
| o6read 10 × 100, 24 cores: req/s | 270.9k (264.0..271.9k) | 272.4k
(269.5..275.4k) | 272.2k (262.8..273.7k) |
| same: allocated / request | 4,713 B | 4,710 B | 4,368 B (−7.3 %) |
| o6read 10 × 1000, 24 cores: req/s | 262.4k | 264.6k | 263.1k |
| `read --nodes 1`, 20 sessions: req/s | 206.2k | 204.9k | 208.9k |
| same: allocated / request | 4,631 B | 4,623 B | 4,397 B (−5.1 %) |
| `read --nodes 100`: allocated / request | 73,534 B | 73,531 B | 73,306
B |
| `browse`: allocated / request | 39,019 B | 39,035 B | 38,707 B (−0.8
%) |
| `sub`: allocated / notification | 139 B | 138 B | 136 B |

CPU per request and throughput change within run-to-run noise for both
items; a further 7-round pinned comparison of master against R2 + R5
(with the clock-read change that was dropped from this PR) gave 29.7 µs
(29.2..31.3) against 29.4 µs (29.1..30.7).

Sampled thread time at 24 cores (cpu trace, first `Opc.Ua` frame above
the frame), master against the combined build:

| Frame | master | after |
|---|---|---|
| `Buffer.BulkMoveWithWriteBarrier` (exclusive) | 11.4 % (`ProcessAsync`
5.1, `TrySendResponseWithoutGate` 3.7, `WriteCoalescedAsync` 1.7) | 0.3
% |
| `CastHelpers.Box` | 2.6 % (`ProcessAsync` 2.0, `NodeId` 0.6) | 0.3 %
(`NodeId`) |

Allocation trace: `ServerSystemContext` 6.7 % → 2.2 % (two copies per
Read, each now 48 bytes); `StringTable`, its `List<string>` and its
`Lock` (about 2.5 %) are gone.

**Why the CPU does not follow the profile.** The sampler suspends
threads at their next GC poll, so frames before a poll site, such as
runtime helpers and returns from native calls, are over-represented.
Once the copies were gone, the samples moved to the next poll sites in
the same code: `List<DataValue>.ToArray` went from 1.3 % to 13 % for a
one-element copy. CPU per request is unchanged in the pinned runs. The
struct copies cost less than their sample share suggested.

## Changes

**R2: no large struct copies per request.**
`EndpointBase.EndpointIncomingRequest` is a sealed class instead of a
mutable struct. It was boxed when scheduled as an
`IEndpointIncomingRequest` and copied again into `CallAsync`'s state
machine, both with write barriers. The class allocation replaces the
box. `UaSCUaBinaryChannel.PendingWrite` shares one payload reference and
two integers between its three kinds (chunk, buffers, unsealed message):
40 bytes instead of about 80 with a nullable `EncodedSymmetricMessage`
inside. Every response is copied into the write queue, the writer's
batch and back after sealing. `BeginWriteSymmetricMessage` takes the
message by `in`.

**R5: slim per-request system context, no string table per request.**
- `SessionSystemContext` keeps the values a copy takes from its template
in one immutable object: namespace and server tables, type table,
factories, handle, session values. A copy shares that object; a setter
replaces it rather than changing it, so copies stay snapshots. Server
contexts build it in one allocation. The per-request
`ServerSystemContext` of a node manager therefore holds four references:
48 bytes instead of about 160.
- `OperationContext` creates its `StringTable` on first use.
`StandardServer` builds the response header of a request that wrote no
diagnostic strings with an empty table of the thread.

## Behaviour notes

- **`EndpointIncomingRequest` (R2):** a protected nested type of
`EndpointBase` changes from a struct to a sealed class. Equality is by
request header as before, and is now null-safe.
- **System context copies (R5):**
- Copies still behave as snapshots: a value set on the template after a
copy was made does not reach the copy, and a value set on the copy does
not reach the template. This holds for derived types that copy
themselves with a plain `MemberwiseClone` too.
- Setting a value on a context allocates a new value object (a context
is set up once, not per request).
- **String table (R5):** a response still carries an empty (not a null)
string table when the request wrote no diagnostic strings. An override
of `ServerBase.CreateResponse(RequestHeader, StringTable)` still runs
for every response. The thread lends its empty table to the call and
takes it back only if the table is still empty when the call returns, so
strings an override adds (also when it then throws) never reach another
response, and a response created re-entrantly gets a table of its own.

## Testing

- **New tests:**
  - `ServerSystemContextCopyTests`:
    - every shared value is set and copied;
    - a copy for a request allocates at most 64 bytes;
- changes stay where they are made: on a copy, on the template, on a
copy of a copy, and on a derived type;
    - a copy for a session;
    - a plain `MemberwiseClone` of a derived type stays a snapshot.
- `StandardServerResponseStringTableTests`: an empty table without
strings; the request's strings; an override that adds strings and
throws, adds strings, or creates a response re-entrantly leaks nothing
into later responses.
  - `OperationContextTests`: the string table is created on first use.
- `ReferenceServerTests`: an empty response string table without
diagnostics, the strings with diagnostics, and an empty table again
after them.
- **Run locally (net10.0):** Opc.Ua.Core.Tests (Stack/Tcp, Transport,
Server), Opc.Ua.Server.Tests, Opc.Ua.Sessions.Tests,
Opc.Ua.Subscriptions.Tests, Opc.Ua.Redundancy.Server.Tests,
Opc.Ua.Stress.Tests Contract + Integration. net48 builds.
- **Measured with:** `perf/ServerLoadHarness` scenarios `o6read` (o6
harness, not part of this PR), `read`, `browse`, `sub`.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 15:06:47 +02:00
Wolfgang Colsman 12a76ade55 PubSub server: organize PublishedDataSets under PublishSubscribe.PublishedDataSets (#4793)
# Description

PubSubNodeManager looked up the parent of the PublishedDataSet and
DataSetFolder objects it materializes by NodeId i=14478. That is
DataSetFolderType's <DataSetFolderName> placeholder, an
InstanceDeclaration
of the type, not the PublishSubscribe.PublishedDataSets object
(i=17371).
Every PublishedDataSet therefore became a child of the type definition
and
the standard PublishedDataSets folder stayed empty.

Use ObjectIds.PublishSubscribe_PublishedDataSets as the parent. The test
harnesses now expose the real folder and the placeholder, and a new test
asserts that a PublishedDataSet ends up under the folder and that the
placeholder gains no children.

## Related Issues

None.

## Checklist

- [x] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [x] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [x] I have added all necessary documentation.
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [x] 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.
- [ ] I have addressed **all** PR feedback received.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Marc Schier <marcschier@users.noreply.github.com>
2026-10-15 14:27:52 +02:00
romanett 95850b7e55 TCP listener: complete requests without an async state machine per request (R7) (#4788)
Roadmap item R7 of the o6 benchmark gap analysis (master `5b03e9afa`
profile). `TcpTransportListener.OnRequestReceivedAsync` was an `async
void` method. Every request allocated an `AsyncStateMachineBox` when it
awaited the request processing, which almost always completes later on a
queue worker. In the profile the box was about 4 % of the server's
on-CPU samples (`GetStateMachineBox`) and 4 % of the allocated bytes.

The listener now completes a request without an async state machine. The
handling of every other outcome is unchanged:
- failures;
- sends that wait for the channel gate;
- closed channels;
- the retry on the session's new channel.

No wire, configuration or public API change.

## 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. 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 → 130,200 | 30.3 → 29.8 (−2 %) |
4,706 → 4,554 (**−152 B**) |
| o6read 10×100 encrypted, 4 cores | 116,300 → 118,500 (+2 %) | 33.4 →
32.8 (−2 %) | 4,747 → 4,606 (**−141 B**) |
| o6read 10×100, 24 cores | 344,500 → 340,200 (−1 %) | 53.6 → 54.1 (+1
%) | 4,710 → 4,509 (**−4 %**) |
| o6read 10×1000, 24 cores | 310,800 → 306,800 (−1 %) | 55.6 → 57.7 (+4
%, noise) | 4,719 → 4,530 (**−4 %**) |
| read --nodes 1, 20 sessions | 189,500 → 200,800 (+6 %) | 49.7 → 49.0
(−1 %) | 4,624 → 4,413 (**−5 %**) |
| read --nodes 1, 200 sessions | 174,500 → 183,200 (+5 %) | 50.2 → 46.7
(−7 %) | 4,510 → 4,322 (**−4 %**) |
| browse | 36,490 → 36,220 | 179 → 174 (−3 %) | 39,004 → 38,796 |
| sub (5 × 100 items, 100 ms) | 94,020 → 93,530 notif/s | 8.2 → 7.5
(rounds 4.5–11.2) | 145 → 143 |

**Effect:**
- **Allocation.** Every request through the TCP listener allocates
140–210 B less (−3 to −5 %).
- **CPU.** CPU per request falls by about 2 % on 4 cores. On 24 cores it
is within the spread of the rounds, where waiting dominates.

### Profile
cpu and alloc traces, 24 cores, 10×100, quiet runs at 433–466k req/s.
The traces were taken with this PR and #4787 (R3) together; the frames
below belong to this PR only.

| Frame | master | this PR |
|---|---|---|
| `GetStateMachineBox` of `OnRequestReceivedAsync`, exclusive | 4.0 % of
on-CPU samples | 0.03 % |
| `OnRequestReceivedAsync` state machine box | 4.1 % of allocated bytes
| 0 |
| `PendingRequest` (pool rent and return) | – | not visible; the time
under `PendingRequest.OnProcessed` (1.9 %) is the channel's send-order
lock |

## Changes
- **`OnRequestReceived`** starts the processing synchronously. A request
still processing is awaited by a pooled `PendingRequest`. It keeps the
request's state in fields and continues with a cached delegate. The
response value task source is pooled already, so a request allocates
nothing for this in steady state. The continuation flows the execution
context, as the `await` did.
- **Synchronous completion.** When the processing succeeded and the
channel queued the response without waiting, the request completes
synchronously. The request and response are returned to their pools and
the pending-request count is released.
- **`CompleteRequestAsync`** is an `async void` method with the previous
body, and handles every other outcome:
- a processing failure, thrown synchronously or later (logged, answered
with a service fault);
  - a send that waits for the channel gate;
- a send on a closed channel (quiet log) and its retry on the session's
new channel.
- **Kestrel TCP and WebSocket** use a different handler (no service
fault, no pending-request scope, no request reuse). They are left
unchanged.

## Testing
- **`TcpTransportListenerRequestCompletionTests` (new)**, with a
loopback listener and a UA-TCP client:
- 1200 concurrent requests on 4 channels each get their own response.
The requests complete synchronously, through a task, or later through a
value task source on another thread, so the pooled continuations are
reused;
- a processing failure, thrown synchronously, returned as a faulted
task, or set later, is logged as an error once and answered with a
service fault, and the channel keeps working;
- a callback value task source that throws when its state is read, the
continuation is registered or the result is read fails only that
request: logged, answered with a service fault, channel keeps working
(these time out without the fix in ee81c83f2);
  - a cancelled request is answered with the endpoint's fault;
- a response completed after the client closed its channel is dropped
with the quiet log and without an error, and the listener keeps serving;
  - no unobserved task exceptions.

  The same tests pass on master.
- **Local, net10.0.** With this PR alone: Opc.Ua.Core.Tests Stack.Tcp +
Stack.Transport, 847 passed, 0 failed. 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;
- 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:53:19 +02:00
romanett 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 d764161a53 Extract core/types fixes for schemas, replay, and source generation (#4801)
# Description

Extract the reusable core/types layer from #4462 without including
UaLens or the other planned extraction layers.

- Preserve null versus empty in `ByteString.Copy`, and provide
older-framework path, random-byte, and SHA-256 convenience APIs.
- Encode abstract numeric structure fields (`Number`, `Integer`, and
`UInteger`) as Variants in JSON, XSD, and BSD schemas, with regression
coverage and schema documentation.
- Add opt-in complete-file validation for finalized PCAP replay while
retaining the existing tolerant reader default.
- Keep placeholder references on generated type templates, not runtime
instances, while preserving ordinary external references.

**Initial extraction:** commit
`8881e096fb5e4eaa36f3c13f8caa57733373bd19` preserves the assigned
changes from source `390c4a7a51b71da40caec2d22ef74518bbe670ce`, based on
pinned `master` commit `5b03e9afa492cdb36049ee52ae844f630f89d8db`: 20
exact full-file extractions and only the generic replay paragraph in
`docs/Diagnostics.md`. The UaLens capture-window paragraph remains with
the source PR. No UaLens projects, UI, native-rendering paths,
dependencies, or workflows are included.

**Review follow-up:** `814c0f2dc6056ea5eb898611565b3e96cfa65104` fixes
direct-placeholder references that bypassed filtering through
`TargetPath` and emitted an undefined `forInstance` guard in
instance-only factories. Filtering now follows target resolution.
Compilation/reference-shape regressions cover ObjectType/VariableType,
both placeholder rules, and direct-child/external targets;
`docs/NodeManagers.md` documents the behavior. The review thread was
resolved after publication and verification.

**CI follow-up:** `93ec3928bd5a9cb2ea86110085e79131db17d443` addresses
both causes found in [CI run
38700770996](https://github.com/OPCFoundation/UA-.NETStandard/actions/runs/38700770996):

- `MODELGEN012` treated an identical payload-less transitive dependency
declaration as a competing model producer after the SDK Programs model
appeared in newer `master`. Matching closure rows no longer emit that
diagnostic. Producer ordering is unchanged; actual producers, differing
declarations, and malformed payload handling retain their existing
diagnostics. New regression cases exercise both reference orders, and
`docs/ModelDependencies.md` records the distinction.
- The queue-fairness test sampled OS scheduling and failed on macOS.
This reuses the exact one-file correction from
`84d2c34d3902bfe700a87f6c30708b435500878f` in #4765: hold the existing
heavy-owner publication hook, prove concurrent work-conserving progress,
then assert exact recovered 1:3 service, all 960 unique requests, and
complete queue/token/owner/lease cleanup. No production scheduler or
fairness bound was changed.

The PR now changes 26 paths. Its pinned extraction ancestry is
preserved: no rebase, force push, or merge of newer `master` was
performed. This remains the bottom of native stack 4807, ordered 4801 ->
4802 -> 4803 -> 4804 -> 4806.

### Current CI-repair validation

Windows, existing .NET SDK **10.0.401**, **Release**, with analyzers
enabled. CI tested merge `9a2d252793da12aeb99ed379dfc9b03e89c912b7`
(master `d40843c23a5fe8116ffe380cf937d88759f5218b` plus PR head
`814c0f2dc6056ea5eb898611565b3e96cfa65104`), which includes newer tests
absent from the pinned extraction branch. That exact merge was
reproduced in a disposable detached worktree, then the proposed patch
was applied. Git patch-id verified that the tested patch and committed
branch patch were identical
(`b7ed3ba2b61d1862825dd33d93c50c64d3a759ea`).

Before the fix, all four failing client-accessor cases reproduced
`MODELGEN012`, and both new matching-reexport cases failed. After the
fix:

| Context / scope | Framework | Passed | Failed | Skipped |
| --- | --- | ---: | ---: | ---: |
| Exact CI merge + patch: client-accessor and dependency fixtures |
net10.0 | 32 | 0 | 0 |
| Same fixtures with CI coverage instrumentation | net10.0 | 32 | 0 | 0
|
| Exact CI merge + patch: entire SourceGeneration.Tests project |
net10.0 | 295 | 0 | 0 |
| Exact CI merge + patch: entire SourceGeneration.Tests project | net48
| 295 | 0 | 0 |
| Preserved PR branch: dependency and placeholder fixtures | net10.0 |
30 | 0 | 0 |
| Preserved PR branch: dependency and placeholder fixtures | net48 | 30
| 0 | 0 |
| Preserved PR branch: entire FairRequestQueueTests fixture, with
coverage | net10.0 | 93 | 0 | 0 |
| Preserved PR branch: entire FairRequestQueueTests fixture | net48 | 93
| 0 | 0 |
| Exact CI merge + patch: entire FairRequestQueueTests fixture, with
coverage | net10.0 | 93 | 0 | 0 |

All nine commands exited **0**. The repository TRX verdict helpers
verified **993 passing test executions, 0 failures, and 0 fixture
setup/teardown failures** across these overlapping runs. No
build/analyzer warnings were reported. Normal restore resolved missing
assets/target entries in the new checkout and when switching TFMs; no
dependency declaration or SDK pin was changed.

Exact successful test argument sets follow. Variables abbreviate the
executable, project paths, and results directory only; output was also
captured to separate local logs.

```powershell
$env:DOTNET_ROOT = 'D:\git\UA-.NETStandard_master\TestResults\ualens-528-deep-repro\sdk'
$env:PATH = "$env:DOTNET_ROOT;$env:PATH"
$dotnet = "$env:DOTNET_ROOT\dotnet.exe"
$generator = 'tests\Opc.Ua.SourceGeneration.Tests\Opc.Ua.SourceGeneration.Tests.csproj'
$coreTests = 'tests\Opc.Ua.Core.Tests\Opc.Ua.Core.Tests.csproj'
$results = 'C:\Users\mschier\.copilot\session-state\8f497e89-7fc2-4f48-880b-4803d97c1c12\files\ci-validation'

# In the disposable exact-CI-merge checkout, with the identical proposed patch applied:
Set-Location 'D:\Temp\ua-ci4801-8f497e89'

# Passed: 32, Failed: 0, Skipped: 0
& $dotnet test $generator --no-restore --configuration Release --framework net10.0 -p:CustomTestTarget=net10.0 -p:RunAnalyzers=true -p:RunAnalyzersDuringBuild=true --filter 'FullyQualifiedName~ClientAccessorGeneratorTests|FullyQualifiedName~ModelDependencyScannerTests' --logger 'trx;LogFileName=ci-metadata-green-net10.trx' --results-directory $results --verbosity minimal

# Passed: 295, Failed: 0, Skipped: 0; reuses the analyzer-enabled build above
& $dotnet test $generator --no-build --no-restore --configuration Release --framework net10.0 -p:CustomTestTarget=net10.0 --logger 'trx;LogFileName=ci-sourcegeneration-merge-net10.trx' --results-directory $results --verbosity quiet

# Passed: 295, Failed: 0, Skipped: 0
& $dotnet test $generator --configuration Release --framework net48 -p:CustomTestTarget=net48 -p:RunAnalyzers=true -p:RunAnalyzersDuringBuild=true --logger 'trx;LogFileName=ci-sourcegeneration-merge-net48.trx' --results-directory $results --verbosity minimal

# Passed: 93, Failed: 0, Skipped: 0
& $dotnet test $coreTests --configuration Release --framework net10.0 -p:CustomTestTarget=net10.0 -p:RunAnalyzers=true -p:RunAnalyzersDuringBuild=true -p:CollectCoverage=true --collect 'XPlat Code Coverage' --settings 'tests\coverlet.runsettings.xml' --filter 'FullyQualifiedName~FairRequestQueueTests' --logger 'trx;LogFileName=ci-fairqueue-merge-net10.trx' --results-directory $results --verbosity minimal

# Passed: 32, Failed: 0, Skipped: 0
& $dotnet test $generator --configuration Release --framework net10.0 -p:CustomTestTarget=net10.0 -p:RunAnalyzers=true -p:RunAnalyzersDuringBuild=true -p:CollectCoverage=true --collect 'XPlat Code Coverage' --settings 'tests\coverlet.runsettings.xml' --filter 'FullyQualifiedName~ClientAccessorGeneratorTests|FullyQualifiedName~ModelDependencyScannerTests' --logger 'trx;LogFileName=ci-metadata-coverage-net10.trx' --results-directory $results --verbosity minimal

# In the preserved PR branch worktree:
Set-Location 'D:\git\copilot-worktrees\UA-wot-d3-final\marcschier-congenial-umbrella'

# Passed: 30, Failed: 0, Skipped: 0
& $dotnet test $generator --configuration Release --framework net10.0 -p:CustomTestTarget=net10.0 -p:RunAnalyzers=true -p:RunAnalyzersDuringBuild=true --filter 'FullyQualifiedName~ModelDependencyScannerTests|FullyQualifiedName~PlaceholderReferenceGeneratorTests' --logger 'trx;LogFileName=ci-sourcegeneration-branch-net10.trx' --results-directory $results --verbosity minimal

# Passed: 93, Failed: 0, Skipped: 0
& $dotnet test $coreTests --no-restore --configuration Release --framework net10.0 -p:CustomTestTarget=net10.0 -p:RunAnalyzers=true -p:RunAnalyzersDuringBuild=true -p:CollectCoverage=true --collect 'XPlat Code Coverage' --settings 'tests\coverlet.runsettings.xml' --filter 'FullyQualifiedName~FairRequestQueueTests' --logger 'trx;LogFileName=ci-fairqueue-branch-net10.trx' --results-directory $results --verbosity minimal

# Passed: 93, Failed: 0, Skipped: 0
& $dotnet test $coreTests --configuration Release --framework net48 -p:CustomTestTarget=net48 -p:RunAnalyzers=true -p:RunAnalyzersDuringBuild=true --filter 'FullyQualifiedName~FairRequestQueueTests' --logger 'trx;LogFileName=ci-fairqueue-branch-net48.trx' --results-directory $results --verbosity minimal

# Passed: 30, Failed: 0, Skipped: 0
& $dotnet test $generator --configuration Release --framework net48 -p:CustomTestTarget=net48 -p:RunAnalyzers=true -p:RunAnalyzersDuringBuild=true --filter 'FullyQualifiedName~ModelDependencyScannerTests|FullyQualifiedName~PlaceholderReferenceGeneratorTests' --logger 'trx;LogFileName=ci-sourcegeneration-branch-net48.trx' --results-directory $results --verbosity minimal
```

`git diff --cached --check` passed. The queue-test blob was verified
byte-for-byte against the existing published correction. The upstream
branch was verified as `93ec3928bd5a9cb2ea86110085e79131db17d443` after
push.

**Remote CI is pending, not claimed green:** [new CI run
38712127138](https://github.com/OPCFoundation/UA-.NETStandard/actions/runs/38712127138).
Cross-platform confirmation, CodeQL, coverage gates, and the complete
`UA.slnx` suites on this new head are not established by these local
checks. The source PR's checks are not claimed as evidence for this
branch.

### Earlier validation

The original extraction had 1,407 passing scoped test executions across
net10.0/net48. The placeholder-review follow-up had six passing focused
cases and 269 passing tests in the full source-generator project on each
framework, with analyzer-enabled builds. Those are historical results
for their respective commits, not fresh full-suite evidence for the
CI-repair head. Earlier command details remain in this PR's edit
history.

## Related Issues

- Related to #4461 (existing tracking issue; not auto-closing).
- Extracted from #4462 (source PR; remains open and is not
auto-closing).
- Reuses the bounded queue-test correction from #4765; does not
incorporate that PR's other changes.

_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))._

This began as an exact extraction of existing behavior. The subsequent
commits address the reported placeholder-generation defect and observed
CI failures; they are not a new design or a claim of design sign-off.

## 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.
2026-10-15 12:21:46 +02:00
Wolfgang Colsman d40843c23a Client: make the UaFileInfo constructor public (#4790)
# Description

FileSystemClient hands out UaFileInfo objects only for files it resolves
by path below its root directory. An application that already knows the
NodeId of a FileType object, for example from a reference or a method
result, could not get a UaFileInfo for it because the constructor was
internal, and had to fall back to the raw FileTypeClient proxy.

The constructor is now public and documented, and it rejects a null
segment list. A test creates a UaFileInfo for a known NodeId and reads
its metadata through it.

## Related Issues

None.

## Checklist

- [x] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [x] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [x] I have added all necessary documentation.
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [x] 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.
- [ ] I have addressed **all** PR feedback received.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Marc Schier <marcschier@users.noreply.github.com>
2026-10-15 09:38:21 +02:00
Marc Schier 15d0ce7bd5 Implement OPC UA Part 10 Programs (#4789)
# Description

Implement OPC UA Part 10 Programs across `Opc.Ua.Core`/`Core.Types`,
`Opc.Ua.Server`, and `Opc.Ua.Client`, reusing the existing
state-machine, source-generation, node-management, auditing, and
streaming-subscription infrastructure.

- Add asynchronous provider-backed execution, standard controls,
internal/autonomous transitions, generation checks, and nested-state
activation/resumption.
- Add checked creation/adoption, vendor Create adapters, deletion,
recycling and instance limits, auto-delete, DI, and direct-construction
paths.
- Maintain ProgramDiagnostic2 and retained final results; publish
captured intermediate results and concrete transition/audit events with
access controls.
- Add generated SDK models/proxies, typed client discovery and reads,
event streams, and subscribe-before-call readiness handling.
- Add unit, connected client/server, generated Annex A-style model, and
NativeAOT coverage. Document usage and migration in `docs/Programs.md`
and related guides.

### Compatibility and design decisions

The linked Part 10 document is v1.05.00; namespace-zero definitions
remain aligned with 1.05.07. Documentation distinguishes normative
requirements from conflicting specification text and SDK conventions.

- Correct state numbers to Halted=11, Ready=12, Running=13,
Suspended=14. Generated subtypes retain inherited namespace-zero
state/transition identifiers.
- Managed Reset is Halted-to-Ready only. Preserve the existing low-level
Reset-from-Suspended behavior for compatibility.
- Preserve UInt32 limit types; use `UInt32.MaxValue` as a documented SDK
unlimited convention. RecycleCount overflow reports BadOutOfRange
without stopping unlimited execution.
- In-memory default with injectable providers. Durable recovery,
distributed execution/failover, and a production Annex A download engine
are excluded.

## 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.
- [ ] I have addressed **all** PR feedback received.

Tests and focused coverage evidence are above; the combined
tests/coverage checkbox remains open because a whole-repository coverage
increase has not been established. CLA status, CI/CodeQL results,
tracking-issue approval, and PR feedback are not asserted.
2026-10-15 09:19:25 +02:00
Wolfgang Colsman 9746c62b49 Server: make TrustList.ValidateNewTrustListAsync public (#4799)
# Description

TrustList validates an uploaded TrustList before CloseAndUpdate commits
it:
every certificate is validated against the new TrustList (the uploaded
certificates and CRLs, or the current stores for lists that were not
specified), CRLs must belong to a CA of the result, and
IssuerCertificates
must be CA certificates. The check was private, so an application that
applies a TrustList by other means, for example a GDS push it receives
through its own channel, could not apply the same check first and had to
duplicate it.

The method is now public and documented; its behaviour is unchanged. A
test calls it for an accepted TrustList, for one whose issuer is missing
and for one that lists a leaf certificate as an issuer.

## Related Issues

None.

## Checklist

- [x] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [x] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [x] I have added all necessary documentation.
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [x] 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.
- [ ] I have addressed **all** PR feedback received.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Marc Schier <marcschier@users.noreply.github.com>
2026-10-15 08:28:16 +02:00
Marc Schier 724757122b Add recycling simulator and plastics/rubber companion packages (#4772)
# Description

Add a generic, company-neutral recycling simulation sample and reusable
plastics/rubber companion-model packages. One durable causal coordinator
and ten separately launched OPC UA process views provide deterministic
material balances, finite buffers, genealogy, utilities, quality
holds/disposition, authenticated controls and crash recovery. Process
views consume committed state instead of running independent
simulations.

Real companion-model equipment instances use DI/Machinery/AMB discovery,
Machine Vision, Pumps/Scales, granulate drying, extrusion, filtration
and pelletizing. Vendor/process extensions remain sample-local.
Connectors and WoT conversion are out of scope. **External
source-contract parity and complete deployment qualification are not
claimed complete.**

The sample and managed tests target **.NET 8, 9 and 10**. The
consolidated [recycling guide](samples/Recycling/README.md) explains the
actual composition, simulation, observation paths and exposed data; the
application-directory README points to that guide.

Typed client member extensions are emitted at build time through the
default-false `ModelSourceGeneratorClientAccessorsOnly` opt-in.
Extrusion uses it for all twelve parts instead of checked-in generated
C# and a Python generator. The shared client runtime retains typed
values, quality, timestamps and streaming behavior.

### Package layout

Six packages contain fourteen models in two model-producing assemblies:

| Specifications | Model | Typed client | Server runtime |
| --- | --- | --- | --- |
| OPC 40083 and OPC 40082-6 | `Opc.Ua.PlasticsRubber` |
`Opc.Ua.PlasticsRubber.Client` | `Opc.Ua.PlasticsRubber.Server` |
| OPC 40084 parts 1–12 | `Opc.Ua.Extrusion` | `Opc.Ua.Extrusion.Client`
| `Opc.Ua.Extrusion.Server` |

NuGet IDs use the `OPCFoundation.NetStandard.` prefix. All six packages
are registered in `.azurepipelines/expected-packages.txt` and the
generated preview-pack solution. Granulate-drying code lives in
`GranulateDrying` folders in the PlasticsRubber projects; extrusion uses
per-part folders. There are no standalone granulate-drying or per-part
extrusion packages. Published model identities and CLR namespaces remain
distinct and unchanged.

The common 1.03 interface is independently authored on the explicitly
MIT-licensed 1.02 implementation basis; the official 1.03 XML is not
redistributed. The other imported models retain their individual MIT
notices. All 42 pinned model, identifier and basis files retain their
manifest-backed bytes, including their staged Git blobs. No formal
companion certification is claimed; the disputed Filter profile is not
advertised.

## Related Issues

No tracking issue is currently linked.

## 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.

Current-head CI and coverage remain pending; the full local `UA.slnx`
test matrix is not claimed complete.
2026-10-15 07:00:58 +02:00
Cristi Pogacean af601ebca8 Complete model-change reporting for committed node deletions (#4778)
# Description

Finalize deletion model-change reporting from observed address-space
outcomes, including when later cleanup fails or the caller cancels the
request.

A shared internal snapshot captures registered subtree nodes,
pre-removal NodeVersion eligibility, and relevant reference endpoints.
Completion reports only removed nodes and references actually removed
from surviving local nodes, then rethrows the original deletion failure.
If reporting also fails, both errors remain visible.

This preserves notifications for versioned descendants below unversioned
roots and applies strict deletion eligibility to the service-facing
overload. It avoids blindly reporting the requested subtree from a
finally block.

For endpoints owned by another node manager in the same server,
reporting requires target-reference cleanup to be both requested and
successful because the remaining foreign references cannot be inspected
locally. A successful service deletion with `DeleteTargetReferences =
false` does not imply foreign reference removal. Reporting covers both
directions of hierarchical and non-hierarchical references; absolute
targets are excluded, matching the cleanup path.

Master cleanup now propagates a bad per-reference `DeleteReferenceAsync`
result as a `ServiceResultException`. A normal return with a bad status
is therefore not mistaken for successful foreign cleanup. Committed
local changes are still reported before the cleanup failure is rethrown;
unconfirmed foreign reference removals are not reported.

The branch includes the merge of current master after #4776 and #4777
merged. This PR preserves their per-node monitored-item restoration,
independent cleanup budgets, and NodeVersion eligibility behavior. It
does not introduce further monitored-item completion changes; the
remaining diff is outcome-based model-change completion, its regression
tests, and documentation. It contains no release-version changes.

## 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))._

- Fixes #4769
- Related: #4768, #4770 (addressed by merged PRs #4776 and #4777).
- Design and failure reproduction are recorded in #4769.

## Checklist

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

- [x] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [x] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [x] I have added all necessary documentation.
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed.
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [ ] I have addressed **all** PR feedback received.

## Validation

### Current head: `524e46fbe`

The server test project and its dependencies built without
build/analyzer warnings or errors. The following command covers the
deletion/model-change regressions, the complete monitored-item lifecycle
fixture, and the master node-management dispatcher fixture:

```powershell
& 'C:\Program Files\dotnet\dotnet.exe' exec 'C:\Program Files\dotnet\sdk\10.0.402\dotnet.dll' test tests\Opc.Ua.Server.Tests\Opc.Ua.Server.Tests.csproj --no-restore -f net48 -p:CustomTestTarget=net48 -p:NuGetAudit=false -m:2 --filter "FullyQualifiedName~AsyncCustomNodeManagerTests&Name~Delet|FullyQualifiedName~AsyncCustomNodeManagerNodeManagementTests&Name~DeleteNode|FullyQualifiedName~MonitoredItemManagerLifecycleTests|FullyQualifiedName~MasterNodeManagerNodeManagementTests" --logger "trx;LogFileName=foreign-cleanup-status-net48.trx" --results-directory 'C:\Users\crpogace\.copilot\session-state\f4f431fc-413e-40d4-88a7-83f6ade0c9a7\files\foreign-cleanup-status-results'
```

Result: **296 passed, 0 failed**; 25 fixture combinations are
inapplicable to the legacy adapter and were not executed.

Regression coverage includes late cleanup failure/cancellation,
pre-mutation cancellation, partial subtree removal, service eligibility,
versioned descendants, preservation of simultaneous deletion/reporting
failures, foreign-reference direction/type combinations, requested
versus omitted cleanup, failed cleanup, and absolute-target exclusion.
New regressions exercise the real master cleanup dispatcher with `Good`,
`BadNotSupported`, and `BadUnexpectedError` results across async
internal/service deletion and legacy runtime deletion. Before the fix,
10 bad-status cases failed; the 5 successful-cleanup controls passed.

**Current-head net10.0 tests were not executed locally:** the machine
now has SDK 10.0.402 rather than the pinned 10.0.401. Restore requires
`Microsoft.NET.ILLink.Tasks` 10.0.13, which is absent from the local
cache, and the NuGet endpoint fails its TLS handshake. This is an
environment/restore blocker, not a test failure. The explicit SDK
invocation above does not change the repository SDK pin.
`NuGetAudit=false` was invocation-only because the audit endpoint was
unavailable; repository audit settings are unchanged.

The earlier pre-merge revision `0c53e143b` passed 96 focused deletion
tests on each of net10.0 and net48, with 22 inapplicable fixture
combinations per framework. Those historical results do not establish
net10.0 validation of the current merged head.

Full net48/net10.0 solution validation has not been completed locally.
The ordinary .NET 10 VSTest invocation cannot run the unchanged AOT
projects' Microsoft.Testing.Platform suites; this is a mixed-runner
invocation limitation, not evidence of an AOT compilation defect. Their
supported validation path is native publish-and-run, as used in CI. A
separate local Historian native AOT run passed 2/2 tests earlier in this
work, but does not establish complete AOT or current-head validation.

CI was still running when this description was updated; the full-suite
and CI checklist items remain unchecked.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-14 14:03:23 +02:00
romanett 5b03e9afa4 Channel send path: seal responses in the writer, lock-free gate, fewer locks and allocations per request (#4782)
Carved out of #4771 (roadmap item A1 of the o6 benchmark gap analysis):
everything except running requests to completion on the receive loop.
#4771 keeps run to completion, stacked on this PR, for a later release.

Requests still go through the fair request queue and its workers, as on
master. What changes is the channel around them:

- the response is encoded without the channel gate;
- the channel's single writer numbers, secures and sends;
- requests take fewer locks and allocations on the channel.

No wire, configuration or public API change.

## Results

Paired A/B against master `6cfb8cf19` (with #4767). The builds alternate
per round on the same machine, and the table shows medians of 5 rounds
with the throughput range of the rounds in brackets. The machine was
shared with other work, so the CPU and throughput ranges are wide;
allocated bytes per request are deterministic.

- **o6read** uses the native open62541 client workers of the
o6-automation benchmark, in the o6 2.0.1 configuration:
`AsyncCustomNodeManager`, Server GC, queue = clients × outstanding + 3.
- **"4 cores"** pins the server to 4 cores.
- **The other rows** use the harness's .NET client: 20 sessions unless
noted, workstation GC, ReferenceServer.
- **Contention** counts runtime lock-contention events in the measured
window.

| Scenario | req/s | Server CPU µs/req | Alloc B/req | Contention |
|---|---|---|---|---|
| o6read 10×100, 24 cores | 254,600 → 264,700 (+4 %) [228–292k →
255–270k] | 67.4 → 69.5 | 5,874 → 4,709 (**−20 %**) | 6,766 → 1,811 |
| o6read 10×1000, 24 cores | 248,900 → 241,300 (−3 %) [231–258k →
233–260k] | 69.0 → 71.3 | 5,866 → 4,718 (**−20 %**) | 6,080 → 1,456 |
| o6read 10×100 encrypted, 24 cores | 203,900 → 232,000 (**+14 %**)
[111–226k → 204–264k] | 71.6 → 70.2 | 5,985 → 4,752 (**−21 %**) | 5,166
→ 1,288 |
| o6read 10×100 `--o6-async 0` (`CustomNodeManager2`), 24 cores |
235,600 → 243,900 (+4 %) | 40.1 → 38.8 | 6,702 → 5,886 (−12 %) | 148k →
124k |
| o6read 1 client × 1000 (one channel) | 64,300 → 92,600 (**+44 %**)
[52–69k → 76–100k] | 79.8 → 74.5 (−7 %) | 6,003 → 5,063 (−16 %) | 224 →
128 |
| o6read 10×100, 4 cores | 156,600 → 140,400 (−10 %, noise: rounds
139–192k → 121–184k) | 25.0 → 28.3 | 5,435 → 4,688 (−14 %) | 97 → 184 |
| o6read 10×1000, 4 cores | 107,100 → 125,200 (**+17 %**) [103–116k →
120–136k] | 36.5 → 31.7 (**−13 %**) | 9,966 → 4,767 (**−52 %**) | 312 →
160 |
| o6read 10×100 encrypted, 4 cores | 103,300 → 126,300 (**+22 %**)
[102–105k → 115–143k] | 37.9 → 31.3 (**−17 %**) | 5,498 → 4,736 (−14 %)
| 46 → 29 |
| o6read 10×100 `--o6-async 0`, 4 cores | 186,600 → 187,700 (+1 %) |
21.1 → 21.0 | 6,587 → 5,915 (−10 %) | 17.7k → 4.2k |
| read --nodes 1, 20 sessions | 208,300 → 205,900 (−1 %) | 49.1 → 48.3 |
4,905 → 4,621 (−6 %) | p50/p99 0.072/1.18 → 0.072/1.26 ms |
| read --nodes 1, 200 sessions | 216,400 → 208,600 (−4 %) | 42.0 → 43.2
| 4,790 → 4,518 (−6 %) | p50/p99 0.606/3.56 → 0.615/4.05 ms |
| read --nodes 100 | 31,950 → 32,470 (+2 %) | 278 → 253 (−9 %) | 73,702
→ 73,436 | p50/p99 0.290/2.51 → 0.283/2.48 ms |
| browse | 38,380 → 40,460 (+5 %) | 162 → 161 | 39,305 → 38,981 | p50
0.349 → 0.330 ms |
| sub (5 × 100 items, 100 ms) | 88,190 → 89,010 notif/s | 11.1 → 10.7 |
159 → 153 | p50/p99 20.7/64.3 → 20.8/66.0 ms |

**Where it helps:**
- Allocation per request falls in every scenario, by 20 % for o6read.
- Lock contention falls by 70–80 % for o6read.
- Throughput rises where the channel gate serialised the work:
  - one deep pipeline on one channel: +44 %;
  - encrypted: +14 % on 24 cores, +22 % on 4 cores;
  - 1000 in flight on 4 cores: +17 %.

**Where it does not:** CPU per request at 24 cores is unchanged. The
requests still go through the fair queue's workers, and the hand-off to
a worker and back is what #4771 removes. The 4-core 10×100 row spread as
much within each side as between the sides.

## Changes

### Sending
- **Seal in the writer.** The producer encodes the response without the
channel gate and queues it unsealed. The channel's single writer takes
the queued responses in order under a per-channel send-order lock: it
assigns sequence numbers, selects the current token, signs and encrypts,
and sends the batch. Messages that number themselves (OpenSecureChannel,
asymmetric faults, the client's requests) hold the same lock until they
are queued. Token changes that can dispose a token take it as well.
- **Reconnect.** The old transport is detached before the new token is
activated. Unsealed responses still queued for it are then dropped
without taking sequence numbers. The new transport is attached and the
OpenSecureChannel response numbered under one hold of the send-order
lock, so the response is the first message on the new transport. The
channel opens again only after that.
- **Failure past the sequence numbers.** A message whose security fails
after its chunks took sequence numbers stops all numbering on that
transport. The writer drops the messages producers numbered for it after
the gap, and reports a transport error once it has left the send-order
lock. That faults the channel (server) or reconnects (client).
- **Numbered writes without the lock.** The writer takes writes that
their producers numbered without the send-order lock, because they are
queued in number order. A client channel queues only such writes, so its
writer no longer waits for the lock that its request threads take.
- **Writer on the pool.** The writer always starts on the thread pool.
Running it on the producer's thread sent batches of one message.
- **Buffers.** The encoded message is a struct in the write-queue entry.
A small single-chunk message moves to a right-sized buffer only when
writes are already queued, so deep pipelines no longer exhaust the
bounded buffer pool (−52 % allocation at 10×1000 on 4 cores).

### Locks and allocations per request
- **`ChannelGate`** takes the gate with one compare-and-exchange when it
is free, and queues waiters under a lock only when it is not.
`SemaphoreSlim` took its monitor on every wait and release, which the
receive loop does for every message. The contract is unchanged: not
re-entrant, FIFO handoff, cancellable waits, synchronous completion when
free. The transport's send lock is a `ChannelGate` too.
- **Namespace and server tables.** Every encoder and decoder reads
`ServiceMessageContext.NamespaceUris`, `ServerUris` and
`MappingVersion`. They are now read lock-free from one immutable
snapshot that setters swap atomically.
- **Allocation-free tracking.** Tracking a pending request
(`PendingRequestScope`) and handing a decoded request out of the gate
allocate nothing.
- **Single-chunk decode.** A request in one chunk is decoded from that
chunk's buffer.
- **Fair-queue entry.** An entry coordinates its cancellation
registration with an interlocked state (`RegistrationState`) instead of
a lock object.

### Client fix
`Session.CloseAsync` disposed the session synchronously, and `Dispose`
waits for the async dispose of the session and its subscription engine.
Closing 1000 sessions at once blocked one pool thread per session while
that dispose needed pool threads, and the close took minutes in
`ServerManySessionsLoadTestAsync(1000)`. `CloseAsync` now awaits
`DisposeAsync`, and dispose awaits an engine that implements
`IAsyncDisposable`, as `DefaultSubscriptionEngine` now does.

## Behaviour notes
- **Wire order and tokens.** Sequence numbers stay strictly increasing
in wire order, and the chunks of one message stay contiguous. Token
renewal applies at a message boundary. After a reconnect, the
OpenSecureChannel response is the first message on the new transport.
- **Back-pressure** (#4707) is unchanged. The receive loop stops reading
above 100 queued writes.
- **Client.** `Session.CloseAsync` no longer blocks a thread on the
dispose.

## Not in this PR
The striped server-stop tokens of #4771 are dropped. Since #4767 a
server request no longer registers with the stop token, because the
deadline monitor polls it.

## Testing
- `UaSCBinaryChannelSendOrderTests` covers:
  - wire order and random interleaving;
  - token renewal (decrypt and verify) for five policies;
  - buffers returned once;
- a seal failure after numbering stops the channel, and a message
numbered after the gap is not sent;
  - the replaced transport;
  - back-pressure;
  - a response that cannot be encoded is answered with a fault.
-
`TcpReconnectKeyAgreementTests.AResponseQueuedForTheDroppedTransportIsNotSecuredWithTheNewTokenAsync`
(new) holds the old transport's sends during a reconnect. It fails on
the previous order. The seal-failure test fails without the gap check.
- Also run: `ChannelGateTests`, `TcpByteTransportTests`,
`ServiceMessageContextMappingTests`.
- Local, net10.0:
- Opc.Ua.Core.Tests (Stack.Server, Transport, Tcp, ChannelGate): 2739
passed, 0 failed;
  - Opc.Ua.Configuration.Tests: 241 passed, 0 failed.

net48 builds. `dotnet format whitespace --verify-no-changes` passes on
the changed files.
- CI: the `CI` workflow on this head is running; the commits were tested
together with run to completion in #4771 (its last full CI run passed).

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:51:14 +02:00
romanett 2f72239a72 Quickstarts alarms: report one event per Enable instead of two (#4780)
Every Enable call on a reference-server alarm produced **two** condition
events (different EventIds, same Time); Disable produced one.

## Cause
`ConditionTypeHolder.OnEnableDisableAlarm` is registered as the
condition's `OnEnableDisable` hook, which
`ConditionState.ProcessBeforeEnableDisable` calls *before*
`OnEnableCalled`/`OnDisableCalled` change the `EnabledState`. The hook
switched the state (`SetEnableState`) and called `ReportEvent()` itself;
the core then ran `UpdateStateAfterEnable` and `ReportStateChange` for
the same change, because every concrete holder sets
`AutoReportStateChanges` (`AcknowledgeableConditionTypeHolder`). On
Disable the hook's `ReportEvent` is skipped (the condition is already
disabled), so only Enable doubled.

Part 9 §5.5.2: a change of `EnabledState` causes a ConditionType Event
Notification — one per change. The duplicate also made the CTT's *A and
C Enable Test_002* flaky: it took the second notification as the answer
to its next call (worked around in the CTT scripts).

## Fix
The hook keeps setting the `Message` ("Enabling" / "Disabling alarm …",
carried by the core's event) and leaves the state change and its event
to the core. For a condition without `AutoReportStateChanges` it still
changes the state and reports, as before.

## Tests

`AlarmsAndConditionsEnableTests.DisableEnableViaTypeAndInstanceMethodNoEventsDuringDisabledAsync`
now asserts exactly one condition event in the Enable call window —
fails on master (`Expected: 1 But was: 2`), passes with the fix. All A&C
history tests (152, 3 runs) and client alarm tests (55) pass.

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 08:39:21 +00:00
romanett d90ff9fd8f Server: release the HTTPS listener's pinned TLS certificate when it fails to open (#4775)
## Problem

A full local `Opc.Ua.Server.Tests` run (net10.0) passed all 8606 tests,
but the assembly-level certificate leak check failed the teardown:

```
Certificate leak detected: 1 instance(s) created but not disposed
UNREACHABLE UNDISPOSED CERTIFICATES:
   at Certificate.AddRef()
   at HttpsTransportListener.PrepareTlsCertificate()
   at HttpsTransportListener.StartAsync / OpenAsync
   at ServerBase.CreateServiceHostEndpointAsync
   ...
   at ServerStartupTests.StartServerAsync(String uriScheme)   (https case)
```

It did not reproduce when `ServerStartupTests` ran on its own.

## Root cause

`HttpsTransportListener.StartAsync` pins the TLS certificate
(`PrepareTlsCertificate` takes an `AddRef` handle) **before** Kestrel
binds the port. If the port is already taken, Kestrel's `IOException`
escapes `OpenAsync`. `ServerBase.CreateServiceHostEndpointAsync` logs
and rethrows it **without disposing the listener**. The listener is not
in `TransportListeners` yet, so nothing ever releases it.

`ServerFixture` then retries on another port and the test passes, but
the pinned certificate is lost. Ports collide when parallel suites race
for the port `GetNextFreeIPPort` hands out, which is why the leak only
showed up under load.

## Fix

- **`ServerBase.CreateServiceHostEndpointAsync`** disposes a listener
that fails to open. Only listeners in `TransportListeners` are disposed
when the server stops, so a failed one has no other owner. The
`listener` parameter doc now states that the server takes ownership.
- **`HttpsTransportListener.StartAsync`** stops the listener again when
the start fails. That releases the pinned certificate and any host it
built, so any caller that drops a listener after a failed open leaks
nothing. If the cleanup also fails, both errors are reported in an
`AggregateException`, the same pattern as
`ServerBase.CompleteServerStartAsync`.

Either change alone fixes the reported leak. Both are kept: the first
fixes the ownership gap for every transport, the second makes the HTTPS
listener clean up after itself whoever the caller is.

## Tests

-
`ServerStartupTests.StartServerOnTakenPortRetriesOnAnotherPortAsync(https
| opc.https)`: holds the port with a dual-mode `TcpListener`, so the
first start fails and the fixture retries. **Before the fix**, the
assembly leak check reported 2 leaked handles (one per scheme) with the
exact creation stack above. **After the fix**, it is clean.
- opc.tcp is not covered: on Windows the UA TCP listener binds even
while another socket holds the wildcard port, so its start does not
fail.
-
`ServerStartupTests.CreateServiceHostEndpointDisposesListenerThatFailedToOpenAsync`:
mock listener whose `OpenAsync` throws; checks that `DisposeAsync` is
called once and the original exception is rethrown.
-
`HttpsTransportListenerTests.OpenOnTakenPortReleasesPinnedTlsCertificateAsync`:
real listener and certificate on a taken port; checks that the pinned
certificate fields are cleared after the failed open.

Each unit test fails with only its own fix reverted. With either fix
alone, the end-to-end test leaves the leak check clean.

## Local verification (net10.0)

- `Opc.Ua.Server.Tests` `ServerStartupTests`: 8/8 passed, leak check
clean.
- `Opc.Ua.Core.Tests` `HttpsTransportListenerTests`,
`WssAdmissionTests`, `SharedKestrelHost*`: 74/74 passed. This includes
the "partially opened listener" tests, which rely on a failed
`OpenAsync`.
- The full `Opc.Ua.Server.Tests` run was stopped partway through, with
no failures up to that point. net48 was not built locally; CI covers the
full suites and the other target frameworks.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:04:27 +02:00
romanett b36cacbeac ManagedSession: skip redundant outer reconnect after channel-manager recovery (fixes BoundClientCannotRetryAcrossFaultedManagedEntryReplacementAsync flake) (#4781)
## Problem


`SessionBindingLiveTests.BoundClientCannotRetryAcrossFaultedManagedEntryReplacementAsync`
(added in #4752) failed once on CI (`test-windows-net10.0 (2/15)` of
#4775, run 38508438526):

```
Assert.That(lease.State, Is.EqualTo(ChannelState.Ready))
Expected: Ready
But was:  TransportReconnecting
```

## Root cause

Only the `ManagedSession` fixture variant (`[TestFixture(true)]`) fails;
the plain-`Session` run in the same job passed. From the failing run's
log:

- 28.476: the budgeted reconnect is exhausted and the channel goes
`Faulted`. `ManagedSession.OnManagedChannelStateChanged` calls
`TriggerReconnect`, so the outer `ConnectionStateMachine` takes over by
design: `Reconnect attempt 0, delay 1025 ms`.
- 28.976 – 29.502: the test's manual `ReconnectAsync` swaps in a fresh
entry and runs its reconnect cycle, which reactivates the session.
`Reconnect completed ... Outcome=success` is logged and the state is
`Ready`.
- 29.502: the outer backoff expires (`ManagedSession: Reconnecting.`).
`HandleReconnectAsync` runs a full transport reconnect on the channel
that was just recovered, and the state goes `Ready ->
TransportReconnecting` just before the test reads `lease.State`.

`ClientChannelManager.ReconnectAsync` behaves correctly: it returns only
after its own swap and cycle have completed. The defect is the redundant
outer reconnect. It disconnects a freshly reactivated channel and costs
an extra transport connection (which could also break the test's "no
retry" connection-count assertion).

## Fix

**`ManagedSession`**
- Count channel-driven session reactivations: `Ready` transitions out of
`TransportConnectedSessionReactivating` on this session's lease. A
participant that failed fatally is removed from the entry before
`Ready`, so it is never counted.
- When a channel fault (`Faulted`/`Closed`) or a keep-alive failure
requests the outer reconnect, record the current count as the trigger
mark.
- `HandleReconnectAsync` (under the recovery writer lock) skips the
reconnect and reports success if a reactivation happened after the mark,
the channel is still `Ready` and no channel recovery is in progress. It
logs `ManagedSession: Reconnect skipped; the channel manager already
reactivated the session.` (`ManagedSession + 30`).
- Explicit `ManagedSession.ReconnectAsync` requests are counted
separately (requested vs. served by a completed reconnect that started
after them). While one is outstanding, the outer reconnect is never
skipped, whatever automatic triggers happen meanwhile. A trigger the
state machine rejects clears the mark, and overwriting the mark with a
later trigger only makes skipping less likely.

**Tests**
- New
`ManagedSessionSkipsOuterReconnectAfterChannelManagerReactivationAsync`
(managed variant only). A gated `IReconnectPolicy` holds the outer
reconnect in its backoff while the test faults the lease, recovers it
through the manager and takes a connection snapshot; it then releases
the gate, waits for `Connected`, and asserts no extra transport
connection and a working session. No wall-clock dependency. Without the
skip it fails with one extra connection.
- New
`ExplicitManagedReconnectRunsAfterChannelManagerReactivationAsync`: an
explicit `ReconnectAsync` joins the gated outer reconnect, a later fault
re-triggers it, and the manager recovers the channel. The explicit
reconnect must still open a new connection. It failed (`Expected:
greater than 9, But was: 9`) before explicit requests were tracked
separately.
- `BoundClientCannotRetryAcrossFaultedManagedEntryReplacementAsync`: in
the managed variant, wait for the outer recovery to start before the
manual reconnect, and await `StateMachine.WaitForConnectedAsync` before
asserting `Ready` and taking the connection-count snapshot.
- `WithSessionAsync` takes an optional `IReconnectPolicy`; new helpers
`FaultLeaseAsync` and `GatedReconnectPolicy`.

## Verification

- Injected `await Task.Delay(200)` after the reconnect cycle in
`ClientChannelManager.ReconnectLeaseAsync` (not committed). The old test
then fails every time with the exact CI assertion, and the updated test
passes.
- `CustomTestTarget=net10.0 dotnet test -c Release -f net10.0 --filter
FullyQualifiedName~SessionBindingLiveTests`: 5 sequential runs, 94/94
passed each time. These runs had the test change only; the
`ManagedSession` skip was not yet in place.
- With the skip: the new test and the original test pass; with the skip
disabled, the new test fails as described above.
- `--filter
"FullyQualifiedName~ManagedSession|FullyQualifiedName~SessionBindingLive|FullyQualifiedName~Reconnect"`
on `Opc.Ua.Client.Tests` (net10.0, Release) with the final code: 514
passed, 2 skipped (the managed-only tests in the plain-`Session`
fixture). Full `Opc.Ua.Client.Tests` / `Opc.Ua.Sessions.Tests` runs and
the net48 build are left to CI.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:02:52 +02:00
Cristi Pogacean 27c17fb410 Preserve NodeVersion eligibility when deleting runtime nodes (#4777)
# Description

Preserve a runtime node's NodeVersion eligibility before removing it
from the address space. Previously, deletion removed the local node
before model-change filtering, which then treated it as an unresolved
external node and emitted a spurious NodeDeleted entry.

This fixes unversioned runtime/session-diagnostics deletion in both
AsyncCustomNodeManager and the legacy CustomNodeManager. Strict mode
suppresses unversioned deletion entries; versioned nodes and the
explicit compatibility mode keep their events. The regression matrix
exercises all four eligibility combinations across both async
configurations and the legacy adapter.

The documentation now cites OPC UA Part 3 section 9.32.2, which requires
NodeVersion/model-change correlation.

This PR is intentionally limited to the original eligibility bug.
Per-node monitoring completion and outcome-based model-change completion
are separate changes tracked in #4768 and #4769, including broader
subtree/service-deletion handling.

## 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))._

- Fixes #4770
- Related: #4768, #4769

## Checklist

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

- [x] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [x] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [x] I have added all necessary documentation.
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed.
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [ ] I have addressed **all** PR feedback received.

## Validation

The server test project and its dependencies built successfully on
net10.0 and net48 with no new build/analyzer warnings. Focused reruns:

```powershell
dotnet test tests\Opc.Ua.Server.Tests\Opc.Ua.Server.Tests.csproj --no-build --no-restore -f net10.0 -p:CustomTestTarget=net10.0 --filter "Name~DeleteNodePreservesModelChangeEligibilityAsync"
```

Result: **12 passed, 0 failed**.

```powershell
dotnet test tests\Opc.Ua.Server.Tests\Opc.Ua.Server.Tests.csproj --no-build --no-restore -f net48 -p:CustomTestTarget=net48 --filter "Name~DeleteNodePreservesModelChangeEligibilityAsync"
```

Result: **12 passed, 0 failed**.

Full-solution .NET 10 validation was attempted, but the unchanged AOT
test projects reject the VSTest target with Microsoft.Testing.Platform
2.4.1 under SDK 10.0.401. The run was stopped after this blocker was
confirmed. It is not reported as a passing suite.

Exact isolated reproduction of the blocker:

```powershell
dotnet test tests\Opc.Ua.Aot.Tests\Opc.Ua.Aot.Tests.csproj --no-build --no-restore -p:CustomTestTarget=net10.0 -p:NuGetAudit=false
```

Result: **failed** with "Testing with VSTest target is no longer
supported by Microsoft.Testing.Platform on .NET 10 SDK and later." The
full net48 solution was not run. Restore/build commands used
`NuGetAudit=false` because NuGet's audit endpoint was unavailable;
repository audit settings are unchanged.

Published as a draft by request until the full validation/CI gates are
satisfied. No release-version files are included; this branch starts
from master.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-14 08:44:23 +02:00
Cristi Pogacean f1e51c19f1 Restore monitoring for survivors of partial node deletion (#4776)
# Description

Complete failed recursive deletion according to each monitored node's
actual state, rather than inferring the entire subtree's outcome from
the root.

Monitored items for removed nodes remain detached and source-deleted.
Items for surviving descendants are restored with their existing
identity and ownership. Restoration attempts continue after individual
failures, and compensation failures are surfaced together with the
original deletion failure.

The change covers internal and service-facing async deletion and the
synchronous compatibility path. Regression tests monitor both the
removed root and surviving child across both monitored-item
implementations.

## 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))._

- Fixes #4768
- Related: #4769, #4770
- Design and failure reproduction are recorded in #4768; this draft
awaits review/sign-off.

## Checklist

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

- [x] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [x] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [x] I have added all necessary documentation.
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed.
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [ ] I have addressed **all** PR feedback received.

## Validation

The server test project and its dependencies built successfully on
net10.0 and net48 with no new build/analyzer warnings. The entire
monitored-item lifecycle fixture, including six new partial-subtree
cases and four new compensation-failure cases, passed:

```powershell
dotnet test tests\Opc.Ua.Server.Tests\Opc.Ua.Server.Tests.csproj --no-build --no-restore -f net10.0 -p:CustomTestTarget=net10.0 --filter "FullyQualifiedName~MonitoredItemManagerLifecycleTests"
```

Result: **63 passed, 0 failed**.

```powershell
dotnet test tests\Opc.Ua.Server.Tests\Opc.Ua.Server.Tests.csproj --no-build --no-restore -f net48 -p:CustomTestTarget=net48 --filter "FullyQualifiedName~MonitoredItemManagerLifecycleTests"
```

Result: **63 passed, 0 failed**.

The full-solution .NET 10 VSTest invocation is blocked by the unchanged
AOT test projects' Microsoft.Testing.Platform configuration. Full
net48/net10 solution validation and CI remain outstanding.

The blocker was reproduced in the original-fix worktree with:

```powershell
dotnet test tests\Opc.Ua.Aot.Tests\Opc.Ua.Aot.Tests.csproj --no-build --no-restore -p:CustomTestTarget=net10.0 -p:NuGetAudit=false
```

Result: **failed** because Microsoft.Testing.Platform 2.4.1 rejects
VSTest under SDK 10.0.401. Restore/build commands used
`NuGetAudit=false` because the audit endpoint was unavailable;
repository audit settings are unchanged.

This is an independent master-based draft. It does not include the
separate NodeVersion eligibility fix or the model-change completion
redesign.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-14 08:43:56 +02:00
Cristi Pogacean 89c1275d9d Fix managed browse closed-channel and cancellation handling (#4774)
# Description

Fix managed browse failure reporting when the session's channel has
closed or the caller has already cancelled the operation.

- Use the existing checked `TransportChannel` accessor in
`ClientBase.MessageContext`, so a closed channel reports
`ServiceResultException(BadSecureChannelClosed)` instead of
`NullReferenceException` during `Browser` construction.
- Check cancellation at entry to
`SessionClientExtensions.ManagedBrowseAsync` and the array-based
`Browser.BrowseAsync`, before session access or request submission.
- Document failure semantics and add unit plus real client/server
regression tests for closed channels, pre-cancelled empty/non-empty
inputs, Browse/BrowseNext request failures and cancellation, and mixed
per-node results.
- Preserve existing propagation of request exceptions and the per-node
result/error contract. No dependency, analyzer configuration, or public
API signature changes.

The real-session before-fix run reproduced three failures (closed
channel, and pre-cancelled closed channel with empty/non-empty input).
All corresponding regressions pass after the fix.

## Related Issues

Fix the closed-channel and pre-cancelled managed-browse behavior
described in the linked bug report.

_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))._

- Fixes #4773

## Checklist

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

- [x] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [x] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [x] I have added all necessary documentation.
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed.
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [ ] I have addressed **all** PR feedback received.

## Validation

Windows, .NET SDK 10.0.401. **134 targeted test cases passed**,
including existing browser and continuation-point regressions:

| Suite | net10.0 | net48 |
| --- | ---: | ---: |
| BrowserTests + BrowserAuditRegressionTests | 27 passed | 27 passed |
| ClientBaseTests | 18 passed | 18 passed |
| ContinuationPointInBatchTest managed-browse cases | 22 passed | 22
passed |
| Total | **67 passed** | **67 passed** |

Tests were added and prove the fix, but coverage percentages were not
collected; the combined tests/coverage checklist item is therefore left
unchecked. CLA status has not been independently verified.

### Exact test commands and results

Commands ran from the repository root. Test output was redirected to
local logs.

```powershell
# PASS: 27 tests.
dotnet test Tests\Opc.Ua.Client.Tests\Opc.Ua.Client.Tests.csproj --no-restore -f net10.0 -p:CustomTestTarget=net10.0 -p:NuGetAudit=false '-p:WarningsNotAsErrors=CA2000%3BCA2007' --filter "FullyQualifiedName~Opc.Ua.Client.Tests.BrowserTests|FullyQualifiedName~BrowserAuditRegressionTests" --logger "console;verbosity=normal"

# PASS: 18 tests.
dotnet test Tests\Opc.Ua.Core.Tests\Opc.Ua.Core.Tests.csproj --no-restore -f net10.0 -p:CustomTestTarget=net10.0 -p:NuGetAudit=false '-p:WarningsNotAsErrors=CA2000%3BCA2007' --filter "FullyQualifiedName~ClientBaseTests" --logger "console;verbosity=normal"

# PASS: 22 tests, using the freshly rebuilt net10.0 test binaries.
dotnet test Tests\Opc.Ua.Sessions.Tests\Opc.Ua.Sessions.Tests.csproj --no-build --no-restore -f net10.0 --filter "FullyQualifiedName~ContinuationPointInBatchTest&Name~ManagedBrowse" --logger "console;verbosity=normal"

# PASS: 27 tests.
dotnet test Tests\Opc.Ua.Client.Tests\Opc.Ua.Client.Tests.csproj -f net48 -p:CustomTestTarget=net48 -p:NuGetAudit=false '-p:WarningsNotAsErrors=CA2000%3BCA2007' --filter "FullyQualifiedName~Opc.Ua.Client.Tests.BrowserTests|FullyQualifiedName~BrowserAuditRegressionTests" --logger "console;verbosity=normal"

# PASS: 18 tests.
dotnet test Tests\Opc.Ua.Core.Tests\Opc.Ua.Core.Tests.csproj -f net48 -p:CustomTestTarget=net48 -p:NuGetAudit=false '-p:WarningsNotAsErrors=CA2000%3BCA2007' --filter "FullyQualifiedName~ClientBaseTests" --logger "console;verbosity=normal"

# PASS: 22 tests.
dotnet test Tests\Opc.Ua.Sessions.Tests\Opc.Ua.Sessions.Tests.csproj -f net48 -p:CustomTestTarget=net48 -p:NuGetAudit=false '-p:WarningsNotAsErrors=CA2000%3BCA2007' --filter "FullyQualifiedName~ContinuationPointInBatchTest&Name~ManagedBrowse" --logger "console;verbosity=normal"
```

### Production build and patch checks

```powershell
# PASS: production Client/Core dependency build, 0 warnings and 0 errors.
# Normal warnings-as-errors policy; no analyzer downgrades.
dotnet build src\Opc.Ua.Client\Opc.Ua.Client.csproj --no-restore -f net48 -p:CustomTestTarget=net48 -p:NuGetAudit=false --verbosity minimal

# PASS: no whitespace errors.
git diff --check
```

All six changed files retain CRLF line endings. No build/analyzer
diagnostics were reported in the changed files.

### Validation limitations

- Initial unmodified-policy test builds failed on unavailable NuGet
audit data (`NU1900`) and unrelated existing `CA2000`/`CA2007`
diagnostics in test code.
- The test commands use the documented offline `NuGetAudit=false` option
and command-only analyzer downgrades. TODO: remove those command-only
downgrades when the existing certificate/transport test diagnostics are
fixed. Repository build/analyzer settings are unchanged.
- An initial full `ContinuationPointInBatchTest` fixture invocation
rebuilt the net10.0 binaries but was stopped during expensive existing
node-cache traversals. It was replaced by the passing managed-browse
subset above.
- The complete `UA.slnx` suites were **not run**. Full-solution
validation, coverage measurement, and CI/CodeQL results remain
outstanding.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-14 08:43:20 +02:00
romanett 6cfb8cf196 Server: remove per-request context copies and lock hot spots from the request path (−16 % allocation per Read) (#4767)
## Description

Five small changes to the per-request server path, found in the CPU and
allocation profiles of the o6 read benchmark (`AsyncCustomNodeManager`,
Server GC, 10 clients × 100 Reads in flight) on master 3a31473cf. Each
change is its own commit and can be reviewed and reverted on its own.
The channel gate (about 40 % of waits) is not touched; that is A1.

### Results

Paired A/B runs against master (0e40e1229), alternating builds per
round, 5 rounds, medians (min..max). The machine is shared with other
sessions: allocated bytes per request are deterministic; CPU per request
is best read from the runs pinned to 4 cores.

| Scenario | master | this PR | Change |
|---|---|---|---|
| o6read 10 × 100, server pinned to 4 cores: CPU / request | 39.1 µs
(35.9..39.8) | 36.9 µs (35.5..37.6) | **−5.6 %** |
| same: allocated / request | 6,198 B | 5,224 B | **−15.7 %** |
| same: req/s | 98.7k (95.4..108.5k) | 104.6k (97.6..105.9k) | +5.9 % |
| o6read 10 × 100, 24 cores: req/s | 183.7k (173.3..198.0k) | 189.8k
(178.9..197.5k) | +3.4 % |
| same: allocated / request | 6,500 B | 5,578 B | −14.2 % |
| o6read 10 × 100 Basic256Sha256, 24 cores: req/s | 159.8k
(149.4..178.7k) | 164.2k (156.7..165.3k) | +2.7 % |
| same: allocated / request | 6,590 B | 5,694 B | −13.6 % |
| `read --nodes 1`, 20 sessions: req/s | 155.3k (141.6..157.6k) | 163.8k
(163.4..166.5k) | +5.5 % |
| same: allocated / request | 5,548 B | 4,689 B | −15.5 % |
| `read --nodes 100`: allocated / request | 74.4 KB | 70.1 KB | −5.8 % |
| `browse`: allocated / request | 39.8 KB | 35.0 KB | −12.2 % |
| `browse`: lock contentions / 1000 requests | 474 | 367 | −23 % |

At 24 cores CPU per request stays within noise, because the time these
changes free now waits at the channel gate (see below).

Per item, pinned to 4 cores (5 rounds; CPU deltas of one item are within
run-to-run noise, allocation deltas are not):

| Item | CPU / request | Allocated / request |
|---|---|---|
| Q1 system context | +1.8 % | −1.7 % |
| Q2 request table | −4.3 % | −1.4 % |
| Q3 request timeouts | −2.8 % | −2.2 % |
| Q4 namespace metadata | −3.5 % | ±0 |
| Q5 read-path allocations | −2.4 % | −10.9 % |

Sampled thread time at 24 cores (cpu trace, first `Opc.Ua` frame above
the wait or copy):

| Frame | master | this PR |
|---|---|---|
| `ServerSystemContext.Copy` (incl. `MemberwiseClone`) | 12.4 % | 0.1 %
|
| `RequestManager` (`ConcurrentDictionary` inside
`EnterValidationScope`, scope `Dispose`, `RequestReceived`,
`CompleteRequest`) | 7.1 % | 0 |
| `RequestLifetime` (CTS registration spin,
ctor/`RegisterExternal`/`DisposeResources`) | 4.8 % | 0.2 % |
| `NamespaceMetadataRegistry.GetAsync` lock | 4.0 % | 0 |
| `RuntimeHelpers.InitializeArray` (`ReadValidationAttributes`) | 2.2 %
| 0 |
| Channel gate (`ChannelGate+AwaitEntryAsync`, inclusive) | 31 % | 46 %
|

## Changes

**Q1: one system context copy per request and node manager.**
`AsyncCustomNodeManager`'s service methods (Read, Write, Browse,
TranslateBrowsePaths, Call, HistoryRead/Update, monitored item and event
services) use the per-request copy from
`OperationContext.GetSystemContext` instead of copying the node
manager's context again. The per-request cache keeps the first node
manager's copy without an extra object and up to eight more, so a
request that touches several node managers no longer evicts the other
one's copy. `ServerSystemContext` copies itself with a copy constructor
instead of `MemberwiseClone` (which allocates through a runtime call)
unless it is a derived type; `SessionSystemContext` gains a protected
copy constructor. Copies per Read went from about three to one.

**Q2: lock-free request registration in `RequestManager`.** #4742 took
the server-wide lock off the request path, but the two
`ConcurrentDictionary`s for executing requests and open validation
scopes stayed the main waiting point after the channel gate (four writes
per request from every worker). `RequestTable<T>` places an entry with a
compare-and-swap into a free slot near a scrambled hash of its key, and
the entry remembers its slot so completion is one more compare-and-swap;
only when every probed slot is taken does an entry go to an overflow set
under a lock. Drains, Cancel, Session close, the timeout sweep and
`Dispose` read the slots one after the other, which keeps the existing
ordering arguments (registration fence vs. Session/Cancel marks, drain
count published before the snapshot). Sharding the dictionaries over
per-processor locks was tried first and only moved about 5 % of waits
into the shard locks.

**Q3: no timer or shared-token registrations per request.**
`RequestLifetime`'s timeout constructor, used by the endpoint for every
request, no longer creates a `Timer` and no longer registers with the
server's request-queue token and the transport token, which every worker
registered with and so contended on. The deadline and the two tokens are
watched by `RequestDeadlineMonitor`: one sweep every 20 ms over
intrusive lists sharded by processor, running only while requests are
monitored. The `params` constructor keeps its synchronous registrations.

**Q4: namespace metadata lookups without a lock.**
`NamespaceMetadataRegistry` reads its caches (found and missing, by URI
and now also by index) without its lock; only filling and invalidating
take it. The generation moves before the caches are cleared, so a lookup
racing an invalidation never reports a stale miss.

**Q5: fewer per-call allocations on the read path.**
`ReadValidationAttributes` fills its attribute-id array element by
element instead of with an array initializer
(`RuntimeHelpers.InitializeArray` per call); single-node Read and Write
requests skip the per-request attribute cache and validate with
`permissionsOnly` (the same three attributes, read once); `ReadAsync`
allocates the list of nodes to validate only when needed. (Review: a
shared static attribute array, a per-thread `Variant[]` scratch array
and a per-thread Read node cache were dropped again because they crossed
virtual calls; the measurements below were taken with them, so Q5's
allocation saving is a little smaller than shown.)

## Behaviour notes

- **Request timeouts (Q3):** a `TimeoutHint` now fires up to 20 ms late
(the sweep period), and cancelling the server's or the transport's token
reaches the request's `CancellationToken` up to 20 ms later instead of
synchronously. A token that is already cancelled cancels the request at
once, and `TryCancel` checks the tokens first, so a token cancelled
before an explicit Cancel still owns the terminal state. The
exactly-once terminal state of #4744 is unchanged: the sweep claims it
through the same compare-and-swap.
- **System contexts (Q1):** a custom node manager that changed fields of
the context it got in a service call now changes the copy shared by that
request's other calls into the same node manager; it never reaches other
requests or the node manager's own `SystemContext`.
- **Request ids (Q2):** `RequestManager.RequestReceived` no longer
throws for a different `OperationContext` with a request id that is
still registered. Requests are tracked by their context; ids come from a
server-wide counter.

## Testing

- New: `ServerSystemContextCopyTests` (every field copied, derived types
kept), `AsyncCustomNodeManagerTests` (one copy per node manager and
request, two managers in one request, not shared across requests;
single- vs multi-node Read verdicts identical incl. a node read twice),
`RequestTableTests` (idempotent add, exactly-once remove, overflow,
concurrent churn while enumerating; drain racing completion on many
threads; Session close and Cancel by handle exactly once while others
register), `RequestDeadlineMonitorTests` (timeout precision with a fake
clock, token propagation, ownership vs. explicit Cancel, no leaked
entries, sweep/cancel/complete race), `NamespaceMetadataRegistryTests`
(cached miss by index invalidated, lookups racing `Invalidate`,
permission changes visible through the cached node).
- Run locally (net10.0): Opc.Ua.Types.Tests, Opc.Ua.Core.Tests (Stack),
Opc.Ua.Server.Tests, Opc.Ua.Sessions.Tests, Opc.Ua.Subscriptions.Tests,
Opc.Ua.Redundancy.Server.Tests, Opc.Ua.Stress.Tests Contract +
Integration. net48 builds.
- Measured with `perf/ServerLoadHarness` scenarios `o6read` (o6 harness,
not part of this PR), `read`, `browse`.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 06:25:31 +02:00
Marc Schier be50f02700 Add additional companion spec MCP tool profiles (#4731)
# Description

Expose existing stack APIs through **337 additional domain MCP tools**:
114 industrial-companion tools, 35 WoT/xRegistry/document tools, and 188
higher-level stack-workflow tools. The fully enabled catalogue contains
**492 tools**; profile selection is not an execution grant.

The packages now follow the reviewed structure:
**`Opc.Ua.Mcp.Workflows`** contains roles/users, files, alarms and
history; **`Opc.Ua.Mcp.WotCon`** contains management/documents and
independently registered live WoT execution. GDS and schema tools remain
separate packages. The executable composes **18 libraries**.

| Workflow | Added domain tools | Profiles |
| --- | ---: | --- |
| GDS directory, certificates, reference-only credentials/tokens and
Part 21 onboarding | 46 | `gds`, `certificates`, `credentials`,
`onboarding` |
| Roles/users, regular and temporary files, alarms/conditions and
historical access | 74 | `roles`, `users`, `files`, `alarms`, `history`
|
| Bounded runtime types, XSD/BSD/UA JSON, PubSub and REST OpenAPI
artifacts | 6 | `schemas` |
| Named PubSub transports, remote configuration, declarative
adapters/bridges and SKS | 52 | `pubsub-runtime`,
`pubsub-configuration`, `pubsub-bridge`, `pubsub-security` |
| Live HTTP(S), MQTT, Modbus TCP and OPC UA WoT forms | 10 |
`wot-runtime` |

- Reuse typed clients, generated proxies, SDK transports, schema
providers and injected services rather than introducing another session
manager or protocol engine.
- Require host enablement plus exact caller/session/secret/network
grants. Protected generic Call/Write/node-management/history/file paths
enforce the same policy.
- Bind classifications, dispatches, file handles and historical cursors
to actual UA generations. Recovery cannot retarget an old handle or
authorized mutation. Preserve acknowledged results after late
cancellation; do not imply atomicity or retry business commands.
- Use opaque secret-store references, explicit writable-store
registration, issuer/resource/caller binding and reserved capacity
before issuance. Report remote success/local persistence failure as
partial completion; redact provider-controlled diagnostic text.
- Bound new named runtimes to eight per host, two per caller,
five-minute idle expiry and a thirty-minute absolute lease by default.
Pending/closing resources consume quota; failed cleanup remains visible.
Credential invalidation and session recovery close dependent runtimes.
- Keep document planning separate from execution, artifacts
root-confined and real JSON inputs typed. Preserve old profile ordinals
while widening the set to 64 bits. CI grades fully enabled privileged
catalogues rather than connection-only views.

**Limitations:** live Kafka remains explicitly unavailable because the
backend cannot enforce policy on broker-discovered connections;
configuration/schema/capability inspection stays available. The latest
public Dekaf v1.23.5 was also checked and its DNS injection remains
internal. No private-API workaround or bootstrap-only security claim was
added. Full legacy Core registration retains its documented
JSON-reflection limitation: native tests isolate its four connection
descriptors and validate the expanded production registrations, not
whole-host NativeAOT support.

## Related Issues

- Fixes #4730.

## 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.
- [x] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [x] I have added all necessary documentation.
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed.
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [x] I have addressed **all** PR feedback received.
2026-10-14 00:14:05 +02:00
romanett 8c52cba0b2 CTT reference server: faster alarm cycle in --ctt mode, MinSubscriptionLifetime 1000 ms (#4766)
Shortens CTT runs against the reference server without changing what the
CTT verifies.

## Changes
- **Alarm simulation step in `--ctt` mode: 1000 → 500 ms.**
`AlarmNodeManagerFactory.SimulationInterval` (default stays 1000 ms) is
passed to `AlarmNodeManager`; `ConsoleReferenceServer --ctt` sets it via
`Quickstarts.Servers.Utils.ConfigureCttModeNodeManagers()` to
`Utils.CttAlarmSimulationInterval` (500 ms) before the node managers are
created. A full alarm cycle (40 steps through all limits) takes 20 s
instead of 40 s. Every alarm still passes through all states and limits;
the alarm NodeIds carry the interval
(`…ExclusiveLevelAlarmType.500.Alarm` in CTT mode).
- **`Ctt.ReferenceServer.Config.xml`: `MinSubscriptionLifetime` 10000 →
1000 ms.** CTT scripts request short subscription lifetimes on purpose
and then wait for them to expire (Subscription Basic 063/016, Durable
012, Minimum 02 004, Publish Min 05 Err-001, Diagnostics 024); the 10 s
floor inflated those waits. Part 4 sets no minimum; the server still
enforces lifetime ≥ 3 × keep-alive.

## Measured (patched CTT 1.05.513 scripts)
| Part | Before | After |
| --- | ---: | ---: |
| A&C (p18), alarm step only + CTT Alarm Cycle Time 20 | 932 s | 576 s |
| Subscriptions (p16), MinSubscriptionLifetime only | 679 s | 579 s |

Combined full run with the matching CTT-side changes (ReadyToRun server
build, publish interval 150 ms, SecureChannel min lifetime 1000 ms, A&C
Refresh script waits): **72 → 54 min**, 0 regressions against the CTT
baseline (the only error is the known CTT-binary defect GDS 019).

The CTT CI repo derives its `Alarm Cycle Time` from
`Utils.CttAlarmSimulationInterval`, so runs against branches without
this change keep the old 30 s setting.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:49:18 +02:00
romanett 40288fa7d8 Tests: weighted FairRequestQueue test asserts the median of three windows (#4763)
# Description

`FairRequestQueueTests.WeightedServiceHoldsUnderConcurrentWorkersAsync`
failed once on GitHub Actions macOS net10.0 (#4753, run 38349863236):
light=124 heavy=276, ratio 2.23 against an expected 2.5-3.5. It passed
on every other CI run and 30/30 locally.

**Root cause.** `TrySelect` dequeues an owner's turn token from the
lock-free ready queue. On the last token of a turn, `TakeTurnToken`
CASes the turn and calls `PushTurn` for the next one. A worker preempted
between those two steps leaves the owner with no queued token while it
is stalled. The work-conserving dispatch serves the other owner in the
meantime, and that share is never made up. Delay injection confirms
this: sleeping 1 ms on every 7th turn publish collapses 1:3 to roughly
1:1 (light=199 heavy=201).

Publishing a turn ahead of time would only cover stalls shorter than one
round (microseconds), while real preemption lasts milliseconds. Making
up the lost share (deficit or virtual time) would mean redesigning the
scheduler. So this PR documents the bounded skew and makes the test
robust instead.

**Changes**
- `FairRequestQueue` remarks: the weighting holds over time, not in
every window, and a preempted worker can cost an owner its share for the
length of the stall.
- `WeightedServiceHoldsUnderConcurrentWorkersAsync` now measures 3
independent windows through `MeasureWeightedServiceAsync` and asserts
that the **median** heavy/light ratio is in 2.5-3.5. The failure message
lists all three windows.
- Each window uses perOwner = 480 and measured = 560, with queue limits
of `2 * perOwner`. That stays under the TestProvider's 1000-request cap
and keeps both owners backlogged.
- Each window still checks that both owners stay backlogged, then drains
the queue and checks that everything is back to zero.

The median tolerates one skewed window and still fails when the
weighting is lost systematically. With stalls injected into one window
only, the median is 3.0 and the test passes (e.g. `light=238 heavy=322;
light=140 heavy=420; light=140 heavy=420`). With stalls injected into
every window, it still fails.

**Validation:** the `FairRequestQueueTests` fixture passes 93/93 on
net10.0 Release. The weighted test passed 30/30 runs pinned to 2 cores
(`start /affinity 3`). `dotnet format whitespace --verify-no-changes` is
clean, and the build has 0 warnings.

## Related Issues

- Flaky test seen on #4753 after #4750 (FairRequestQueue without a
queue-wide lock)

## Checklist

- [x] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [x] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [x] I have added all necessary documentation.
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed.
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [ ] I have addressed **all** PR feedback received.

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:48:18 +02:00
Marc Schier 3e433eb6c4 XRegistry: registration lifecycle, identifier allocation, registrar roles and atomic Resource replace (#4756)
# Description

Extracts the generic xRegistry registration lifecycle from the WoT work
into a standalone, master-targeted change:

- Canonical xRegistry identifier allocation (`XRegistryIdentifier`) with
case-folded bounded collision handling and exact Version
allocation/delete semantics.
- Registrar roles / capabilities in the xRegistry NodeSet2 model (only
the roles/capabilities facts).
- Default-Version views and Resource Meta handling.
- FileType read/write modes, session ownership/discard, cursor handling,
and short reads until actual EOF on the client.
- Atomic whole-document `ReplaceAsync` on the in-memory and file-system
resource stores.
- Generic projection engine/provisioning and projection contracts, label
ownership/no-op behavior, Resource-vs-Version event targets.
- Documentation in `docs/XRegistry.md` and XML docs for owned members.
- Tests: Identifier, RegistrarRoles, Attribute, Projection,
RegistrationEvent, FileMode, ResourceStore, Client, VersionAssignment,
concurrent read.

Intentionally excluded (handled in separate PRs): trusted Federation /
Registry origin model and provider (G9); WoT lease/dispatcher, prepared
manifest, 0.7 model/version bump (G12).

Merged current `master` (a14b23e7d) with an ordinary merge; no
conflicts.

## Qualification (head a8fb2d01)

- `b9b4f224`: `Opc.Ua.XRegistry.Tests` 411/411 passed on net10.0 and
net48 (raw exit 0). TRX SHA256 net10 `4F669A1A...B02B`, net48
`3EDBFF2A...7D29`.
- `a8fb2d01` differs from `b9b4f224` by one added blank line (static
whitespace-only proof, CRLF intact); no rebuild/rerun of tests on
`a8fb2d01`.
- `a8fb2d01`: default-analyzer `dotnet format --verify` on
`Opc.Ua.XRegistry.Tests` reports 0 findings in lines changed by this PR;
128 pre-existing baseline findings are untouched.
`Opc.Ua.XRegistry.Server`: 0 owned (13 baseline) as of `b9b4f224`.
- Not claimed: full UA.slnx, native, or all-CI results.

## Related Issues

- No tracking issue; split of the WoT/xRegistry work.

## 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.
- [ ] I have added all necessary documentation.
- [ ] 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.
2026-10-13 18:48:07 +00:00
Marc Schier c2b95e2085 XRegistry: trusted logical-Resource federation identity (#4755)
# Description

Adds trusted **logical-Resource** federation identity to XRegistry - A
federation proxy binds an origin registry (`RegistryOriginDataType`),
application, portable `ResourceNodeId` and Xid, never an endpoint or
content-digest identity.

- `XRegistryFederationTarget`, `IXRegistryFederationProvider`,
`XRegistryClient.Federation` (verify/follow/relocate; uses master's
`ISessionBindingProvider.CreateBindingAsync`).
- `XRegistryFederationNodeManager`, `XRegistryServerOptions`/DI:
required target/provider verification and proxy admission.
- Model hunks only: `RegistryOriginDataType` 63026 (+encodings
63591/63592), `OriginRegistry` 63593 + `ResourceType` HasProperty,
`ExternalReference`/`ResourceUrl` description rewrites.
- Tests (`XRegistryFederationNodeManagerTests`), package READMEs,
`docs/XRegistry.md` Federation section (links `SessionBindings.md`).

Merged current master (a14b23e7) into the branch (ordinary merge, no
conflicts).

## Dependency merge gate (not in this PR)

- Group 8 owns registrar roles/capabilities (63027-63029, 63594-63600)
and generic registration/ProjectionEngine/FileType work. When G8 lands,
the model XML must be combined by whole-element merge keeping both
slices (one additive-tail textual conflict is expected).
- Model 0.7 / date / version and csproj package-version bump are
unallocated here (integration owner, Group 12). This is a partial model,
not a new release.
- WotCon live federation fixtures need the WoT registry base partial
(G8/G12) and are excluded.

## Verification (source b6a68701dc)

- Secured live Verify/Follow harness
(`XRegistryClientFederationLiveTests`, 15 cases, real
`XRegistryProjectionEngine` test host): **15/15 passed on net10.0** (TRX
SHA256 `F54DB38E...E518`) and **15/15 passed on net48** (local guarded
runs, raw exit 0).
- Owned `dotnet format --severity info --verify-no-changes` findings: 0.
Remaining findings are baseline only (`XRegistryClient.cs:532` RCS0055;
unrelated WotCon.Tests lines).
- Client fix: `CaptureFederationBindingAsync` maps only binding-capture
`ObjectDisposedException` to `BadSessionClosed`.
- **Not claimed:** full UA.slnx test run, coverage, NativeAOT, or CI
results; the `XRegistryFederationNodeManager` unit-test async changes
and `ClearChangeMasksAsync` change are source changes pending CI.

## Opaque identity capture (source
fb5ea84644)

- `XRegistryFederationTarget` and `XRegistryClient` now own a deep copy
of opaque (ByteString) `NodeId`/`ExpandedNodeId` identities before
portable identity capture (internal `XRegistryNodeIdCopy`,
compile-linked into the client; namespace index/URI/server index
preserved, non-opaque ids unchanged). No public API change.
- Public regression tests (`XRegistryOpaqueIdentityTests`):
`TargetResourceNodeIdIgnoresLaterBufferMutation`,
`TargetOriginRegistryNodeIdIgnoresLaterBufferMutation`,
`ClientRegistryNodeIdIgnoresLaterBufferMutation`,
`TargetNonOpaqueNodeIdsKeepTheirIdentity`.
- RED on old source (b6 + tests only, `b840e654`, net10.0): 3 mutation
tests failed, non-opaque control passed (TRX SHA256
`01B4868E...BEA3B9A`).
- GREEN at `d02480d9` (net10.0 and net48): 4/4 passed; full secure live
harness 15/15 on net10.0 and net48.
- `fb5ea84` differs from `d02480d9` only by a final newline in the new
test file (`git diff -w` empty); runtime results above were executed at
`d02480d9`, not re-executed at `fb5ea84`. Tests-project formatter at
`fb5ea84`: clean.
- Format: `XRegistry`/`Server` clean; `Client` only baseline
`XRegistryClient.cs:532` RCS0055; WotCon.Tests not re-run (restore
assets missing; hunks unchanged since b6 proof).
- **Not claimed:** full UA.slnx, coverage, NativeAOT, or CI results.
## Related Issues

- None.

## Checklist

- [ ] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [x] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [x] I have added all necessary documentation.
- [ ] 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.
2026-10-13 17:32:52 +00:00
romanett 3a31473cf4 Server: no server-wide or per-session locks in per-request session validation (#4753)
# Description

Every request passes through `SessionManager.TryGetSessionContext` at
admission and again at dispatch
(`FairRequestQueue.GetRevalidationStatus` →
`DefaultServerResourceIsolationProvider` →
`ISessionBindingProvider.TryGetSessionContext`), and then through
`Session.ValidateRequest`. Together they took:

1. the server-wide `m_bindingsLock`, to confirm the candidate binding
was still the committed one;
2. the per-session diagnostics lock in `Session.HasExpired`. It is
inlined, so CPU traces charge its spin to `TryGetSessionContext`;
3. the session lock in `Session.IsSecureChannelValid`;
4. the session lock around all of `Session.ValidateRequest`, plus the
diagnostics lock twice (contact refresh, diagnostic counters);
5. the server-wide `SessionManager` event lock, to raise
`SessionDiagnosticsChanged` for every request even when nothing
subscribes.

With the FairRequestQueue lock also removed (a local change without the
queue-wide lock, not yet a PR), these were the next waiting points:
14–16.5 % of on-CPU samples spun in `Lock.TryEnterSlow` under
`TryGetSessionContext`, and once that was fixed another 7–10 % under
`Session.ValidateRequest`.

## Changes

**SessionManager: confirm the binding without the bindings lock**
- `SessionActivationState` publishes a `DispatchBinding` (the committed
binding while no activation is committing, otherwise `null`) with
`Volatile.Write`. The `BindingContext` and `IsCommitting` setters
republish it, and every writer of those already runs under
`m_bindingsLock`.
- The lookup reads it once for the candidate and once to confirm. A
commit always creates a new `SessionBindingContext`, so an unchanged
reference means nothing replaced it. A token is mapped to a session
object at most once and is never re-added after removal, so finding the
token still mapped to the same session afterwards means both held at the
instant of the confirming read, which is the guarantee the lock gave.
- IsCommitting, transfer, close/claim, shutdown and the
certificate/security checks are unchanged.

**Session: read the channel id without the session lock**
- `SecureChannelId` is published with a volatile write in `Activate`,
and `IsSecureChannelValid` reads it without the session lock.

**Session: request validation and diagnostic counters without locks**
- Request counts are interlocked increments of per-session slots (total
and error per `RequestType`, plus unauthorized). They are applied to
`SessionDiagnostics` under the diagnostics lock whenever it is read
(`ReadDiagnostics`, `UpdateDiagnostics`, the diagnostics-node callback,
`ClientLastContactTime`), so readers see the same totals.
- The contact refresh is a compare-and-swap on the tick count, and
requests within the same millisecond do not write. An expiry observed by
`HasExpired` or by a refresh is recorded in the contact word
(`kExpiredFlag`), so a request that read the clock before the monitor
saw the timeout cannot refresh it afterwards. This keeps the documented
"a request cannot revive a session the monitor saw expire" guarantee
without the lock. The intermediate commit that made `HasExpired`
lock-free on its own had opened that window; this commit closes it.
- `ValidateRequest` takes no session lock: the channel id and
`Activated` are published with volatile writes.
- `RaiseSessionEvent` returns before taking the event lock when a
`DiagnosticsChanged` event has no subscriber.

Behaviour differences reviewers should know about:
- `ClientLastContactTime` is millisecond-granular, and two concurrent
refreshes can publish their wall-clock times in reverse order, which
leaves it up to about 1 ms older than the latest contact.
- After an expiry has been observed, an `Activate` no longer refreshes
the contact time. `SessionManager` already rejects activation of an
expired session before that point.

## Tests
- `LookupDuringActivationCommitReturnsNoBindingAsync`: a lookup inside
the commit window (after `Session.Activate`, before the binding commit)
returns no binding. A test session re-implements `ISession.Activate` to
run the hook. Before this test, publishing the binding regardless of
`IsCommitting` passed all 690 session tests.
- `LookupRacingTransfersAndCloseReturnsOnlyCommittedBindingsAsync`: 4
readers look up while 1000 transfers alternate between two channels,
followed by a close. Each result must be a committed binding of the
queried channel, no older than the last completed activation and never
decreasing, and nothing may come back after the close.
- `RequestRacingObservedExpiryDoesNotReviveTheSessionAsync`: a clock
hook lets the monitor observe the expiry between a request's clock read
and its refresh, and the request must fail with `Bad_SessionClosed`. It
fails if `HasExpired` does not record the expiry.
- `ConcurrentRequestsAreCountedExactlyAsync`: 8 threads × 2100
validations (Read/Browse, plus rejected Writes). The totals, per-service
counts, error counts and unauthorized count must be exact, and a
concurrent reader never sees the total go backwards.
- Local runs (net10.0): `Opc.Ua.Server.Tests` 8487 passed / 0 failed;
`Opc.Ua.Sessions.Tests` 851 passed / 0 failed / 379 skipped. net48
builds.

## Measurements
`perf/ServerLoadHarness`, Server GC, paired alternating A/B, 7 rounds.
Baseline is master 8b360da7d + that FairRequestQueue change; candidate
adds the three commits.

| scenario | CPU µs/req | alloc B/req | lock contentions/req | req/s |
|---|---|---|---|---|
| read 1 node, 10 sessions × 100 in flight | 25.4 → 24.1 (−5 %) | 5456 →
5455 | −66 % | +1 % |
| read 1 node, 200 sessions | 42.9 → 43.7 (+2 %) | 5225 → 5212 | −60 % |
−5 % |

CPU and throughput differences are within this shared machine's
run-to-run noise (CPU ranges overlap on both sides). Allocation and
contention per request are the stable signals.

CPU trace (10 × 100), share of on-CPU samples spinning in
`Lock.TryEnterSlow`, 2 runs each:

| build | total | under `TryGetSessionContext` | under
`Session.ValidateRequest` |
|---|---|---|---|
| baseline | 20.8 / 23.7 % | 14.0 / 16.5 % | 0.8 / 0.9 % |
| + binding snapshot | 18.0 / 16.6 % | 12.5 / 11.3 % (inlined
`HasExpired`) | 0.9 / 0.7 % |
| + lock-free `HasExpired` / channel id | 9.5–13.5 % | 0 | 4.8–9.9 % |
| + lock-free validation and counters | 3.5 / 3.4 % | 0 | 0 |

The spin moves rather than disappearing: `Monitor.Enter_Slowpath`, from
the channel locks in `TcpServerChannel.HandleIncomingMessageAsync` /
`SendResponseAsync` and `ChannelGate`, rises from 37–40 % to about 47 %.
That is the next waiting point.

On master alone (FairRequestQueue lock still in place), CPU/req and
alloc/req changes are within noise.

## Related Issues

- Follow-up to the o6 benchmark gap work (#4742, #4743).

## Checklist

- [ ] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [x] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [x] I have added all necessary documentation.
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed.
(Opc.Ua.Server.Tests and Opc.Ua.Sessions.Tests on net10.0; net48
compiles)
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [ ] I have addressed **all** PR feedback received.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 10:32:16 +02:00
romanett a14b23e7dd Server: cheaper symmetric crypto path, FairRequestQueue without a queue-wide lock (#4750)
# Server: cheaper symmetric crypto path, FairRequestQueue without a
queue-wide lock

Roadmap items E1 and 5b of the o6 benchmark gap analysis, one commit
each.

- **E1:** Windows one-shot `Aes.EncryptCbc`/`DecryptCbc` import the key
(`BCryptImportKey`) on every message, although #4720 caches one `Aes`
per channel token. The AES-GCM and ChaCha20-Poly1305 policies also
created a new keyed instance per message.
- **5b:** with `AsyncCustomNodeManager`, which is what the o6
benchmark's 2.0.1 worker uses, the `FairRequestQueue` gate became the
largest waiting point. `TryEnqueue`, `TrySelect` and `TryDequeue` spun
on one `Lock` for every request.

No wire, configuration or public API changes.

## Results

These are paired A/B runs against `origin/master`, five rounds per
scenario. The two harness builds alternate per round, starting side
swapped every round, on the same shared machine (Ryzen AI 9 HX 370, 12
cores / 24 threads, Windows 11). The tables show medians, with ranges in
brackets where they matter.

The server is configured like the o6 2.0.1 worker:
`AsyncCustomNodeManager`, Server GC, queue sized clients × outstanding +
3. o6read uses the benchmark's native open62541 client workers; `read`
uses the harness's .NET client. On this machine throughput moves between
rounds by up to 2×, so CPU and bytes per request are the reliable
signals.

### E1: symmetric crypto (measured on 0dff94bd3)

| Scenario | req/s | Server CPU µs/req | Server alloc B/req | Client CPU
µs/req | Client alloc B/req |
|---|---|---|---|---|---|
| read --nodes 1, Basic256Sha256 SignAndEncrypt, 24 cores | 115,300 →
103,600 (ranges overlap) | 64.6 → 60.9 (**−6 %**) | 5,698 → 5,486 | 44.9
→ 41.7 (**−7 %**) | 2,672 → 2,456 |
| o6read 10×100, encrypted, 4-CPU mask | 71,300 → 75,900 | 51.8 → 51.4 |
6,323 → 6,111 | – | – |
| o6read 10×100, encrypted, 24 cores | 123,500 → 118,200 (ranges
overlap) | 115.7 → 117.2 | 6,716 → 6,515 | – | – |
| o6read 10×100, unencrypted, 4-CPU mask (control) | 79,100 → 70,600 |
48.0 → 48.4 | 6,088 → 6,090 | – | – |

Isolated costs per call (one pinned core, 224-byte chunk, variants
alternating in one process):

| Operation | Before | After |
|---|---|---|
| AES-256-CBC encrypt | 778 ns, 128 B (one-shot) | 329 ns, 24 B (cached
transform) |
| AES-256-CBC decrypt | 682 ns, 128 B | 239 ns, 24 B |
| AES-256-GCM seal | 1,022 ns, 72 B (new `AesGcm`) | 260 ns, 0 B
(cached) |
| HMAC-SHA256 | 306 ns (per-token instance, unchanged) | – |

For comparison, `HMACSHA256.TryHashData` costs 458 ns and
`IncrementalHash` 318 ns.

**E1 does not close the 20–50 µs encrypted gap, and no crypto change
could.** Pinned to four CPUs, encryption costs master only about 3–4 µs
per request (51.8 vs 48.0 µs). The extra cost at 24 cores comes from
threads spinning on locks while signing and encryption run inside the
channel gate (roadmap A1).

The ~7 µs of HMAC in the report's profile is a thread-time artefact.
`HashProviderCng.AppendHashData` shows 40× more samples on the sign
side, inside the gate, than on the verify side for the same chunk sizes.
Threads are preempted there while spinners hold the CPUs.

### 5b: FairRequestQueue (unencrypted, E1 has no effect here)

| Scenario | req/s | Server CPU µs/req | Server alloc B/req | Contention
events/s |
|---|---|---|---|---|
| o6read 10×100, 24 cores | 149,000 → 150,200 | 110.8 [106–117] → 99.6
[96–109] (**−10 %**) | 6,404 → 6,367 | 4,533 → 2,125 |
| o6read 10×1000, 24 cores | 152,200 → 161,200 | 106.7 → 96.3 (**−10
%**) | 6,421 → 6,358 | 4,131 → 1,921 |
| read --nodes 1 --sessions 200, 24 cores | 126,100 → 141,200 (+12 %) |
53.6 → 51.1 (−5 %) | 5,353 → 5,294 | 4,078 → 455 (**−89 %**) |
| o6read 10×100, 4-CPU mask | 85,600 → 83,900 | 45.7 [44.1–47.8] → 46.8
[44.4–48.9] (noise) | 6,089 → 6,060 | 281 → 176 |

Traces of o6read 10×100 at 24 cores, one run per side:
- **Lock spinning.** In the sampled CPU profile, frames above
`Lock.TryEnterSlow` in the fair queue fell from **16.6 % of on-CPU
samples** (`TryEnqueue` 5.8, `TrySelect` 5.8, `TryDequeue` 4.9) **to
none**.
- **Contention events in the window:** 15,331 → 5,879.
- **What remains of the queue:**
- the wake channel in `DequeueAsync`: idle workers parked on the
coalesced signal, 1.3 % of on-CPU samples, kept unchanged;
- `SessionManager.TryGetSessionContext`'s server-wide `m_bindingsLock`,
which every request takes at admission and again at dispatch. It is now
the largest spin point (~26 % of `Lock.TryEnterSlow` samples) and is a
separate follow-up. The channel gate (A1) is another ~17 %.

In the o6 configuration all ten sessions map to **one** owner, so
per-owner locks would not have helped. An intermediate version with a
lock per owner FIFO left 12.8 % of the samples on those locks. That is
why the FIFO itself is lock-free.

## Changes

### Symmetric cipher cache (E1)
- `SymmetricCipherCache` replaces `AesCache`; a `ChannelToken` keeps one
per direction (`ServerCiphers`, `ClientCiphers`). Per slot it retains:
  - a CBC encryptor keyed with the token's key and IV;
  - a CBC decryptor keyed the same way;
  - the AES-GCM or ChaCha20-Poly1305 instance.
- **How a CBC message is processed:** the cached `ICryptoTransform`
transforms the whole message in place with `TransformBlock`. An empty
`TransformFinalBlock` (padding `None`) then rewinds the chaining state
to the IV without re-keying, on CNG, OpenSSL and .NET Framework CAPI
alike. Cached and fresh ciphertext are tested equal.
- **Concurrency and ownership:**
- A cipher is taken out of its slot while in use, so concurrent senders
never share one.
- It is matched to the key and IV by reference, and replaced if the key
material differs.
- It is disposed instead of returned after a failed transform, or once
the token has been disposed.
- **Unchanged:**
  - The uncached public `CryptoUtils` overloads keep the one-shot path.
- The order is still verify, then decrypt, then padding, so a tampered
padding byte under a valid signature is still reported as padding, after
the signature check.
  - HMAC is unchanged.

### FairRequestQueue (5b)
- **No lock in admission or dispatch.**
- Each owner has a lock-free FIFO: Michael–Scott, with the entries
themselves as the nodes, so queueing allocates nothing. The
`LinkedListNode` per request is gone.
- A request is claimed exactly once, by a compare-and-swap from
selection, cancellation or stop. Claimed entries are dropped lazily when
they reach the head.
- **Weighted round robin over a lock-free ready queue of turn tokens.**
- A turn holds one token per queued request, up to the owner's weight. A
weight of 1,000,000 therefore costs no more scheduling state than the
backlog.
- Taking a turn's last token queues the owner's next turn at once,
behind the other owners. The turn state is one 64-bit word changed by
CAS, so the service order is the token order even while workers race.
Weights 1:3 under 8 concurrent workers stay within 2.5–3.5.
- **Failed execution admission:** the head goes to a small sorted list
under the owner's gate, served before the FIFO. Its owner is not retried
before capacity changes, as before.
- **Owner FIFO retirement:** an owner FIFO is retired by an atomic CAS
of its count from 0 to a sentinel, and a retired FIFO can never be
counted again. The next request of that owner starts a new FIFO, and the
retired FIFO's leftover tokens are skipped.
- **Unchanged:**
  - The count bound is reserved with `Interlocked` before linking.
- Global FIFO mode still selects one at a time, so a blocked head cannot
be overtaken.
- Wake coalescing (#4707), parked-request decoupling and
`ServerBase.RequestQueue`'s worker growth.

## Behaviour notes
- **Cipher cache memory:** a token now keeps up to two CBC transforms
(plus their `Aes`) or one AEAD instance per direction, instead of one
`Aes`. They are released with the token.
- **Weighted ordering under concurrency:** dispatch order across owners
follows token order. Within an owner, admission order is kept, including
requests that fail execution admission and go back to the front.
- **Idle scheduling state:** cancelled requests leave stale turn tokens
in the ready queue until the next dispatch pass. They are discarded at
once when the last queued request is cancelled.
- **net48:** the BouncyCastle-backed AEAD types are cached like the
platform ones.

## Testing
- **New and extended tests:**
- `CryptoUtilsAesCacheTests`, for all CBC and AEAD policies
(Basic128Rsa15, Basic256, Basic256Sha256, Aes128_Sha256_RsaOaep,
Aes256_Sha256_RsaPss, ECC_nistP256, ECC_nistP256/P384/RSA_DH AesGcm,
ChaChaPoly):
    - cached vs fresh ciphertext for 0–300-byte bodies;
    - IV or key change;
- tampered signature, ciphertext, tag and header, each rejected with the
cache recovering;
    - bad padding under a valid signature;
    - 256 concurrent senders;
    - token disposal during a transform, and a return racing disposal.
  - `FairRequestQueueTests`:
    - turn tokens bounded by the backlog, not the weight;
    - cancellation leaves no tokens behind;
    - 1:3 weights under 8 workers;
    - no lost wakeups (4 producers, 8 waiting workers, both modes);
- cancellation racing dequeue completes or dispatches each request
exactly once and returns every lease;
    - stop while workers wait and admissions race;
    - a 50-owner, 16-worker stress run with cancellations;
- pipelined clients (1×200, 10×100, 64×1) with ample and with scarce
execution capacity.
- The last test stalls on an earlier version of this change that briefly
marked an emptying owner as retired, when the race window is widened
with an injected delay. That bug stalled o6read 10×100 every time in the
harness; a dump showed 1,000 queued requests with no turn tokens.
Without the delay the window is too narrow to hit at unit level.
- All 90 FairRequestQueue tests pass 30/30 repeated runs on net10.0 and
5/5 on net48.
- **Suites,** net10.0, after rebasing on 8b360da7d (#4748):

  | Suite | Passed | Failed | Skipped |
  |---|---|---|---|
  | Opc.Ua.Core.Tests | 6,357 | 0 | 88 |
  | Opc.Ua.Server.Tests | 8,483 | 0 | 5 |
| Opc.Ua.Sessions.Tests (all secure-channel policies) | 851 | 0 | 379 |
  | Opc.Ua.Stress.Tests Contract + Integration | 45 | 0 | 0 |
| Opc.Ua.Interop.Tests (1.5.378 peer, SignAndEncrypt Basic256Sha256,
Aes256_Sha256_RsaPss, ECC_nistP256) | 218 | 0 | 29 (AEAD policies the
1.5.378 peer does not offer) |
- **net48:** Opc.Ua.Core.Tests builds, and CryptoUtilsAesCacheTests plus
FairRequestQueueTests pass. `dotnet format whitespace` is clean.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 06:28:12 +02:00
romanett 63adef0d10 Fix the Sessions LoadTest fixture and the server bottlenecks it exposed (#4749)
The `[Explicit]` Sessions `LoadTest` fixture has not started since #4541
(also on the 2.0.0 tag): it set `MaxChannelCount = MaxSessionCount =
10100`, and resource isolation refuses to start unless the channel cap
covers the sessions plus the bootstrap and reconnect floors plus one.
Every LoadTest case failed in `OneTimeSetUp`. Running the fixture again,
and profiling it and the `perf/ServerLoadHarness`, found several
bottlenecks that shipped in 2.0.

## Changes

**Test**
- `MaxChannelCount = MaxSessionCount + 3`, the same margin as the
framework defaults (100 / 103).
- Notifications are counted with per-node `Interlocked` counters. The
shared `ConcurrentDictionary<NodeId, int>`, updated by 750
subscriptions, took 13 % of the process CPU in bucket-lock contention.
- After the writer stops, and after the last event, the tests wait until
the received count stops growing instead of a fixed delay, and log the
drain time.

**Server / client**
- **Detached monitored item recovery** (#4094): every node a NodeManager
added (`AddPredefinedNodeAsync`, recursively for children) scanned every
monitored item of every subscription for detached items.
`CreateSubscription` adds ~30 diagnostics nodes, so with 450k monitored
items subscription creation was quadratic: 69 % of CPU, and
`CreateSubscription` timed out. `MonitoredItem` now keeps an exact
process-wide count of live detached or deleted items, and
`MasterNodeManager` skips recovery while it is zero. All flag changes go
through one helper under the item lock; a disposed item does not count,
because it cannot be attached again.
- **Client channel gate** (#4232):
`UaSCUaBinaryClientChannel.SendRequestAsync` entered the gate with a
blocking `Gate.Enter()` inside an async method, and `SemaphoreSlim.Wait`
spun for 19 % of CPU with many requests in flight on one channel. It now
awaits `Gate.EnterAsync(ct)`, like the server side.
- **ResourceManager**
- Status code and symbolic id translation looked up their maps under the
manager lock and translated while holding it. Sessions that request
diagnostics do this for every notification: 83 % of lock contention. The
maps are copy-on-write and read without the lock.
- Translation tables were `SortedDictionary<string, string>` with the
default, culture-sensitive comparer: up to 1 µs per Browse display name,
22 % of Browse server CPU. They now use an ordinal `Dictionary`.
- **Namespace default permissions**: every permission check read
`DefaultAccessRestrictions` / `DefaultRolePermissions` /
`DefaultUserRolePermissions` of the node's namespace through two
server-wide locks and deep-copied the role permission array, per node.
That was 99 % of lock contention for Read/Write of 100 values. The
values are now read once per namespace per service request and
remembered on the `OperationContext`. Contexts that are not a service
request (monitored item and session contexts, which can live long)
remember nothing, so a changed default applies to them at once.

## Results

`perf/ServerLoadHarness`, paired A/B against `master` (3 alternating
rounds, medians, 20 sessions, 24 cores). The harness numbers predate the
recovery, client gate and status code translation commits, which those
scenarios do not exercise:

| Scenario | Throughput | Server CPU / op | Server alloc / op | Lock
contention |
|---|---|---|---|---|
| Read, 100 values | +19 % | −28 % | −18 % | −96 % |
| Read, 100 values, SignAndEncrypt | +27 % | −26 % | −18 % | −98 % |
| Write, 100 values | noise ¹ | −19 % | −17 % | −88 % |
| Browse (Server object) | +3 % | −16 % | −10 % | −2 % ² |
| Read, 1 value | +12 % | −4 % | ±0 | ±0 |

¹ Throughput varied run to run on the shared machine; per-op CPU and
allocation are the reliable signal.
² Browse contention is the Server node's browse lock in
`NodeState.CreateBrowser` (all sessions browse the same node); not
changed here.

`LoadTest` (opc.tcp, Release, in-process client and server):

| Case | master | this PR |
|---|---|---|
| `ServerReadLoadTestAsync` | setup failure | passes (50M value reads, 0
errors) |
| `ServerManySessionsLoadTestAsync(500)` | setup failure | passes |
| `ServerSubscribeLoadTest_Classic` | setup failure; on 2026-09-26
(before #4541): `CreateSubscription` `BadRequestTimeout` | 76–85 %
received, **fails** (needs 99 %) |
| `ServerSubscribeLoadTest_ManagedSessionV2` | setup failure;
2026-09-26: 1,885 notifications received | 35–45 M notifications, 77–85
% received, **fails** |
| `ServerEventSubscribeLoadTestAsync` | setup failure; 2026-09-26: 34.6
% | passes: 100 % delivered, drained ~40 s after the last event |

The subscribe cases now lose notifications rather than deliver them
late: delivery stops ~2 s after the last write. Every monitored item has
queue size 1 and the writer is not paced by delivery, so whatever
exceeds the publish rate (~585k notifications/s here, SignAndEncrypt
with the client in the same process) is overwritten.

The event test lost nothing either: it waited a fixed 2 s after the last
event and counted the backlog as lost. It now waits until delivery
stops. Its throughput is bound by garbage collection: the event consumer
of the Server node spends ~90 % of its wall time suspended for
workstation GCs of the shared test process. With `DOTNET_gcServer=1` it
generates 702 instead of ~210 events/s and drains in 7 s instead of ~40
s, and the subscribe cases reach 87 % (Classic) and 93 %
(ManagedSessionV2) at 78–106 M notifications.

## Testing
- `Opc.Ua.Server.Tests` 8468 passed / 5 skipped, `Opc.Ua.Client.Tests`
2788 passed, `Opc.Ua.Sessions.Tests` 851 passed / 379 skipped,
`Opc.Ua.Core.Tests` Tcp/Channel 797 passed (net10.0).
- New `OperationContextDefaultPermissionsCacheTests`.
- `Opc.Ua.Server` builds for net48; `dotnet format whitespace` clean.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 06:26:29 +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
Marc Schier 84d6387a4f Make request cancellation own exactly one terminal state (#4744)
# Description

`RequestLifetime` now publishes the winning cancellation status and
terminal ownership in a single atomic state, so concurrent client
Cancel, Session close and timeout can never overwrite each other's
status or double-own the terminal transition. External token
registrations are owned by the lifetime and released on completion, and
disposal is deferred until running cancellation callbacks have finished
(no use-after-dispose of the CancellationTokenSource).

Adds regression tests: 16384-iteration race tests asserting
status/owner/callback outcomes with a finite 2 min watchdog
(`RequestLifetimeTests`, `RequestManagerTests`) and `WaitAsync`
cancellation tests (`TaskWaitAsyncTests`; the polyfill change is only 2
XML doc lines, no runtime change).

Files: `RequestLifetime.cs`, `System.Threading.Tasks.cs` (docs), and the
three test files. `StandardServer.cs` is intentionally untouched
(already on master).

## Related Issues

- None.

## Checklist

- [ ] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [x] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [x] I have added all necessary documentation.
- [ ] 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.

Validation actually run (net10.0 and net48, all passed): Types.Tests
`TaskWaitAsync` 23/23; Core.Tests `RequestLifetime` 16/16; Server.Tests
`RequestManager` 56/56; `dotnet format whitespace --verify-no-changes`
on owned files. Full UA.slnx not run.
2026-10-12 18:14:15 +00:00
Marc Schier 184f19a2b0 Add complete method invocation results and resolved diagnostics (#4747)
# Description

`MethodInvocationResult` carries the operation result,
declaration-ordered outputs and optional position-preserving input
results. Asynchronous callbacks retain the actual receiver, cancellation
token and resolved diagnostic strings. Fluent predefined, typed,
instance, reference-child, resolved and virtual method builders reject
conflicting handlers in either registration order and never overwrite
materialized handlers. Direct configuration remains available.

Shared Call-result assembly retains input diagnostics, including useful
text on Good input positions, and requested operation diagnostics on
Good results. Diagnostic encoding preserves nested Good-status text,
status information bits and the outer-inclusive depth limit. Request
admission clears client-supplied internal diagnostic permission flags
before early returns or validation failures; admitted sessions grant the
flag only after the existing administrator-role checks.

Includes corresponding unit/integration regressions, complete XML API
documentation, and method-result guidance consolidated with the fluent
APIs in `docs/NodeManagers.md`. No WoT
receiver/projection/remote-execution, event-readiness/native-admission,
or bulk/history implementation is included.

## Related Issues

Part of the user-approved two-wave extraction plan tracked by #4511.

_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))._

- Related to #4511.

## 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.
2026-10-12 18:26:04 +02:00
romanett 8b360da7d2 Server publish path: no deep clone per Publish, pooled notifications that are actually reused, ready list instead of an O(N) scan (#4748)
Roadmap item A5 of the o6 benchmark gap analysis. Every Publish response
deep-cloned its NotificationMessage in `SentMessageQueue` (PR #4707 kept
the clone because a retained message returns its pooled payload when the
client acknowledges it, which can happen before the response was sent).
This PR replaces the clone with a send-completion lease, makes the
activator pool that recycles the notifications O(1) and sizes it for the
server's load, builds messages without intermediate copies (the
notification array is rented), and lets monitored items tell their
subscription when they have something to report so the publish timer no
longer walks every item.

## Results

Paired A/B against master `0dff94bd3` (includes #4742 and #4743;
alternating builds, 3 rounds, medians; 20 sessions unless noted,
workstation GC, 24-core machine shared with other sessions). Server =
the reference server child process.

| Scenario | Server alloc / op | Server GC pause | Server CPU / op |
Write→notify p50 / p99 | Throughput |
|---|---|---|---|---|---|
| `sub`, 5 subs × 100 items, pub 100 ms | **−57 %** (373 → 159 B) |
**−57 %** | −7 % ¹ | −1 % / −2 % | +1 % ² |
| `sub`, 5 subs × 100 items, pub 10 ms | **−57 %** (375 → 163 B) | **−52
%** | +9 % ¹ | −22 % / +1 % | ±0 ² |
| `sub`, pub 100 ms, SignAndEncrypt | **−57 %** (380 → 163 B) | **−53
%** | +4 % ¹ | −11 % / **−12 %** | ±0 ² |
| `sub`, 2 sessions × 1 sub × 1000 items, 20 changing, pub 50 ms (9
rounds) | −2 % ³ | ±0 | −23 % ³ | ±0 / −2 % | ±0 |
| `counters`, 4000 items, 10 ms sampling, pub 1 s (per notification) |
−2 % | −6 % | −6 % | – | ±0 |
| `o6counters`, 2048 items (per sample) | −3 % ⁴ (111 → 108 B) | −5 % |
−7 % | – | ±0, all PASS |
| `o6counters`, 4096 items (per sample) | −3 % ⁴ | +14 % ⁵ | −6 % | – |
±0, all PASS |

¹ CPU per notification varies by ±15 % between rounds on this machine;
in an earlier 8-round batch against `071660354` the same scenarios
measured −14 % / −1 %. Read it as "no regression". Client CPU per
notification moved −18 %…0 %, within its spread.
² Bounded by the writer rate.
³ The server is nearly idle (0.05–0.12 cores, 360 notifications/s; its
allocation is the writer and keep-alives). The first 3 rounds measured
+31 % CPU, 6 more rounds −23 % (medians 969 → 742 ms per window):
noise-bound, no regression.
⁴ With #4743's sampling permission cache a sample costs 111 B; one
Publish per second carries 2048/4096 notifications.
⁵ The activator pools no longer allocate their 1023-slot arrays up
front; the heap is a few MB smaller, which shifts the GC's generation
budgets so that some low-allocation runs make every collection gen1 (26
of 26 instead of 2). Pause totals differ by milliseconds per 10 s window
(37 → 42 ms here, 21 → 20 ms at 2048 items). Allocating the arrays
eagerly restored master's gen1 counts in a separate test.

In a single CPU trace of `sub` at pub 10 ms, recycling acknowledged
payloads (pool `Return`) went from 16 % of server on-CPU time (master,
the full pool scanned per return) to 5 %.

## Changes

- **Send completion instead of a deep clone**
(`NotificationMessageLease`, `LeasedPublishResponse`,
`SentMessageQueue`): the retransmission queue and every Publish response
that carries a message each own a reference. The transports already call
`IPooledEncodeable.Reuse()` on a response once it is encoded or dropped;
the Publish response is now a `LeasedPublishResponse` whose `Reuse()`
releases its reference. The pooled payload (`NotificationMessage`,
`DataChangeNotification`/`EventNotificationList`,
`MonitoredItemNotification`/`EventFieldList`) is recycled when the last
owner releases it, so an acknowledgement that overtakes the send no
longer recycles a payload that is still being encoded, and the queue and
the response share one message. A reference that is never released only
leaves the payload to the GC; every owner releases at most once.
Keep-alive messages are owned by their response alone. Republish, the
durable snapshot and the retransmission mirror still copy.
- **Transports**: the HTTPS (binary and JSON), WebSocket (UA-SC, JSON,
OpenAPI) and Kestrel TCP listeners and the WebAPI endpoint dispatcher
now release responses after encoding, as the TCP listener does, so
leased payloads are recycled there too. A response retained for a
reconnect is released when it is sent; a send that throws leaves the
response to the GC.
- **Activator pool** (`BoundedObjectPool`): rent and return scanned the
1023 slots linearly; with the pool full every returned notification
scanned all of them before being dropped (16 % of server CPU at 94k
notifications/s). It is now a `ConcurrentQueue` behind the lock-free
first slot with an `Interlocked` bound (a locked stack spun under
contention when transport threads return whole messages). The queue is
created on first use, so activators of types that are never recycled no
longer allocate a 1023-slot array each. The client decoder rents through
the same pool.
- **Pool bound** (`PooledEncodeableType<T>.EnsureMaximumRetained`,
internal): the bound can be raised, never lowered. The subscriptions
raise the `MonitoredItemNotification` and `EventFieldList` pools to
twice the number of monitored items in the process (a notification is
recycled about two publishing cycles after it was built), between the
default 1024 and 65536. At the cap the retained notifications take about
5 MB; the bound stays raised after items are deleted.
- **Message construction**
(`Subscription.InnerPublish`/`ConstructMessage`,
`MonitoredItem.Publish`): the per-Publish `Queue<>`/`List<>` objects are
reused by the subscription; messages are built at their final size (no
`List`→`ArrayOf` copy, no `AddItem` reallocation); the
`MonitoredItemNotification[]` of a message is rented from `ArrayPool`
and the message carries an exact-length slice of it; the lease returns
the array (cleared) with the payload, and messages dropped on queue
overflow return theirs at once; only arrays the subscription rented are
tracked, so restored, mirrored and copied messages never reach the array
pool; the `DiagnosticInfo` list is only allocated when a notification
has one; timestamps are stripped on the struct before the pooled
notification is filled.
- **Ready list** (`IReadySignalingMonitoredItem`,
`Subscription.PromoteReadyMonitoredItems`): `MonitoredItem` acquires a
ready signal (compare-exchange, a full fence after its flags are set)
when it queues a value or event or a resend is requested, and hands
itself to the subscription's intake list. The publish timer checks only
these candidates instead of every item under the subscription lock. An
item stays a candidate while it has something pending that can become
reportable without a new value (sampling deadline, aggregate interval,
samples of a SAMPLING item, trigger flag); after releasing the signal
the subscription checks again, so a value queued concurrently is never
missed. Created, restored, modified, monitoring-mode and triggering
changes mark candidates, as does an item that leaves the publish list
with something pending.

### Behaviour notes (custom subscription or monitored-item extensions)
- **Publish responses share the retained message.**
`PublishResponse.NotificationMessage` returned by
`SubscriptionManager.PublishAsync` is the retained message itself, not a
copy, and its payload objects return to the activator pools after the
response was sent and acknowledged. Code that keeps or modifies the
message of a Publish response (for example in a
`StandardServer.PublishAsync` override or a request interceptor) must
copy it first (`CoreUtils.Clone`). `Republish` still returns a copy.
- **Custom transports**: a transport that does not call `Reuse()` on
responses keeps working; the payload is then collected by the GC instead
of being pooled.
- **Custom `IMonitoredItem` implementations** (not derived from
`MonitoredItem`) cannot signal readiness: a subscription that contains
one scans all its items on every publishing cycle, as before.
`MonitoredItem` subclasses signal through the base class.
- **`DataChangeNotification.MonitoredItems` of a published message is a
slice of a pooled array**: read it through `ArrayOf<T>` (`Span`,
indexer, `Count`); code that unwraps the underlying array would see a
longer array.
- **Order within a message**: notifications of different monitored items
appear in the order the items became ready rather than in the internal
list order. OPC UA does not define an order across items; the order of
the values of one item is unchanged.

## Harness (`perf/`)
- `--write-nodes K` for `sub`: the writer changes only the first K
monitored nodes, for large subscriptions in which few items change.
- `server-scenarios.txt`: publish-path scenarios (`--pub 10`, 1000 items
per subscription with 20 changing).

## Testing
- New `NotificationEncodingLifetimeRegressionTests` cases: an
acknowledgement before send completion does not recycle the payload
still being encoded (the send completion does); Republish after the send
completed returns the original content and stays independent of the
recycled original; a failed send that releases the response twice, plus
a copy of the response, recycles the payload exactly once and only after
the queue released it; a keep-alive is owned by its response; the rented
notification array stays intact until both acknowledgement and send
completion (either order), the message encodes only its slice, and queue
overflow returns the arrays of dropped messages. The existing 30-case
matrix (publish / queued / republish / mirrors × acknowledge / evict /
clear × data / events) now asserts that a shared payload survives every
queue mutation until its response is released.
- New `ReadyListRegressionTests`: the same scenarios on a subscription
that uses the ready list and on one that scans (it also has an item that
cannot signal) must publish the same notifications, for triggering
(sampling items, backlog of the triggering item), monitoring-mode
changes (Sampling↔Reporting, Disabled, disabled before publish),
disabled publishing with keep-alives and ResendData, a value queued
while the item was in the publish list, and the backlog of an item that
left Reporting while in the publish list. Mutation check: removing the
item's signal fails 5 of 6, removing the subscription-side marks fails
the backlog case. Plus: a published item without pending values is not
tracked; creating 1200 items raises the notification pool bounds to at
least 2400.
- `PooledEncodeableTypeTests`: the raised bound is respected and never
lowered.
- net10.0 on the rebased branch: Server 8481 passed (5 skipped);
Sessions 851 (379 skipped); Client 2788; Subscriptions.Classic 57;
Subscriptions.Durable 36 (11 skipped); Redundancy.Server 884,
Redundancy.Client 134, Redundancy.Samples 17; Stress
Contract+Integration 45; Subscriptions 657 (6 skipped); Types pool tests
165. All 0 failed.
- net48 build of `Opc.Ua.Server.Tests` and `Opc.Ua.Bindings.Https` and
`Opc.Ua.Types.Tests` clean; `dotnet format whitespace` clean.
## Not changed (follow-ups)
- Republish still copies the message (`ISubscriptionManager.Republish`
returns a `NotificationMessage`, so the response cannot carry a lease
without an API change).
- `EventFieldList[]` per event message could be rented like the data
change array.
- Sampling remains the larger share of allocation and CPU in the
`counters`/`o6counters` scenarios.
🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:00:05 +02:00
Marc Schier 39dd5632e3 Fix cache invalidation after accepted async indexed writes (#4746)
# Description

After the full async write hook accepts an indexed update, merge the
slice into the whole cached value when possible. If the cache is missing
or stale and cannot absorb the slice, clear the cached value, set its
status to `BadWaitingForInitialData`, and reset its timestamp to
`DateTimeUtc.MinValue`. Preserve the handler's successful operation
result, including `GoodClamped`, and report a value change. Never expose
the replacement slice or stale data as the whole successfully updated
cache. Synchronous fallback and simple-hook behavior are unchanged.

The existing fixture remains intact, with its unusable-cache
expectations updated for this intentional behavior correction. Fourteen
additional cases cover successful merges, missing/stale cache
invalidation, Good/GoodClamped operation results independently of cached
status, handler rejection, type/rank validation, node/user access, and
unsupported status/source/server timestamps. XML documentation explains
the cache-versus-operation contract. Scoped analyzer cleanup uses
asynchronous fixture helpers without removing the cancellation,
lock-release, access, type, status, timestamp, or change-mask oracles.

**Validation:** final head `aaf78b8df1bed46fb14033f3f3c9f52945469000`
passed 32/32 cases on actual net10.0 and 32/32 on actual .NET Framework
4.8, with zero failures or skips. Both owned fixtures and the owned
production file passed formatting/analyzer verification at info
severity. The normal-reference .NET Standard 2.0 Types build passed with
zero warnings/errors. The full `UA.slnx` suite and numerical coverage
collection were not run locally; no full-suite or coverage-percentage
claim is made.

## Related Issues

Related to
[#4511](https://github.com/OPCFoundation/UA-.NETStandard/pull/4511), the
approved Wave A extraction plan. This standalone master PR does not
close the tracking PR or imply completion of the remaining extraction
groups.

## 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.
- [ ] I have addressed **all** PR feedback received.
2026-10-12 14:29:43 +02:00
Marc Schier 4fd4535362 Fix managed-channel certificate ownership on early create failure (#4745)
# Description

`CreateChannelAsync` now releases transferred certificate/chain handles
when disposal or argument validation fails before transport creation.
Managed entries use the existing inherited channel-creation method
directly and share entry-retained, idempotently disposed handles with
the transport, without adding another reference for non-adopting
transports. The ownership contract is documented on the existing method;
no internal redeclaration or forwarding implementation is needed. New
and changed ownership APIs have multiline XML summaries and parameter
documentation.

Fourteen regression cases use generated certificates persisted in an
isolated directory store and resolved through the real
`CertificateManager.CertificateProvider`. They sign and verify SHA-256
digests to prove private-key usability through failed open/retry,
publication/rotation, forward/reverse connection, and shutdown, and
probe each certificate core's final retirement independently of global
counters. Both adopting and non-adopting transports and optional chains
are covered. Temporary stores are cleaned up.

## Related Issues

Related to #4511: user-approved independent Wave A group 2 extraction.
This PR does not close the overall tracking issue or include the other
extraction groups.

_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))._

## 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.
- [ ] I have addressed **all** PR feedback received.

Tests were added and verified as described above; the coverage checklist
remains unchecked because coverage was not measured.
2026-10-12 14:29:21 +02:00
romanett 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
romanett 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
Marc Schier 1bb4bdca1d Add Machine Vision Parts 1 and 2 (#4733)
# Description

Implement the released [OPC
40100-1](https://reference.opcfoundation.org/specs/OPC-40100-1) and [OPC
40100-2](https://reference.opcfoundation.org/specs/OPC-40100-2)
specifications with generated models, provider-backed runtimes, typed
clients, MCP workflows, and DI/Machinery integration.

- **Part 1 stays in `Opc.Ua.Vision`** and its client/server packages
alongside the distinct Perception model. Provide control/acquisition
state machines, jobs and synchronized steps,
recipe/product/configuration/result management, file transfers,
diagnostics, events, and conditions.
- **Part 2 is in `Opc.Ua.MachineVision`, `.Client`, and `.Server`.**
Provide asset categories, identification, image-handling aspects,
health/maintenance data, topology, and transactional updates. The base
Vision assemblies do not reference these optional packages; the Part 2
wire model does not depend on Part 1.
- Preserve the release-pinned model bytes, wire namespaces and NodeIds.
Perception types are in `Opc.Ua.Vision.Perception`; the AMCM generated
namespace remains `Opc.Ua.Vision.MachineVision.AMCM`, and asset
client/server extension namespaces remain `Opc.Ua.Vision.Client` and
`Opc.Ua.Vision.Server`.
- Use `AddOpcUaMachineVision` and `AddOpcUaMachineVisionAMCM` for model
loading and factory registration. Each has node-collection,
encodeable-factory-builder and node-state-factory-builder overloads.
These short entry points delegate to the generated implementations;
existing namespace-derived generated names remain available for
compatibility.
- Support multiple independent named systems/assets under one
namespace-owning manager. Reuse injectable providers, file binding,
bounded memory stores, optional transactional `IFileSystemProvider`
persistence, state machines, telemetry and conformance infrastructure.
- Keep one `docs/Vision.md` guide, titled `Machine Vision (OPC 40100-1
and OPC 40100-2)`, and one `Opc.Ua.Mcp.Vision` package. Include
deterministic server/client samples.
- Expose 17 Machine Vision MCP tools, including capabilities/status,
bounded relationships and observation, complete small-file
uploads/downloads, management, explicit control, conditions, and chunked
transfers. Catalogue counts: 39 Vision-specific, 43 with connection
tools, 81 Vision+Robotics, 155 Full. Every Machine Vision wire parameter
has a schema description.

**Compatibility and limits:** `AddVision()` remains Perception-only
unless released parts are selected. Standalone Part 2 uses
`AddVision().AddMachineVision()`; Machinery composition uses
`AddVisionToMachinery()`. Existing tool defaults and command signatures
are preserved. Observation owns and cleans up its subscription: 5
seconds/50 notifications by default, capped at 60 seconds/100
notifications. Complete-file tools are capped at 1 MiB; larger files use
chunked transfers. Domain Error values remain distinct from UA service
failures.

**Versioning:** the seven Vision/MachineVision/MCP packages and their
Debug variants follow the repository root version instead of being
forced to preview. Vision.OpenUsd and its descendants remain
preview-only. Development builds still inherit the root prerelease
version. No release version, tag, release branch, publication policy, or
feed promotion is changed.

**Review actions:** all fourteen review threads have action/evidence
replies and are resolved. Changes include the unified guide, Perception
namespace, separate Part 2 packages and final naming, multi-system
ownership explanation, interrupted-write cleanup, observer failure
isolation, complete MCP parameter descriptions, and explicit package
inventory. The preview-layout migration topic and the requested
ReleaseProcess caveat were removed. The test Publish collector now
retries recognized transient failures within one cancellation budget and
propagates other exceptions. Commit 69b1f5db9 applies the requested
short loader names and renames standalone hosting to `AddMachineVision`,
with callers, tests and documentation updated. The intervening master
merge is preserved.

### Verification

**Latest API naming changes, verified locally:**

| Scope | Result |
| --- | --- |
| Full Vision suite, net10 | 572 passed, 1 existing skip, 0 failed |
| Full Vision suite, net48 | 515 passed, 0 skipped, 0 failed |
| Final loader/hosting cases after cross-TFM null-guard adjustment | 9
passed, 0 failed, 0 skipped |
| Machine Vision MCP and profile cases | 198 passed, 0 failed, 0 skipped
|
| Main and MCP AOT test-project compilation | Both built with 0
warnings/errors |
| MCP AOT companion, managed execution | 5 passed, 0 failed, 0 skipped;
not claimed as native execution |

`ModelLoaderNamesRegisterTypedFactories`,
`ModelLoaderNamesRejectNullArguments`, and
`GeneratedModelsLoadTogetherAndCreateTypedInstances` cover all six
loader overloads, fluent return identities, typed instances, namespace
coexistence and null guards. Existing standalone Part 2 tests verify
idempotent registration, one-owner enforcement and null handling through
`AddMachineVision`. No source-generator implementation, CLR namespace,
wire identity or model payload was changed for these naming updates.

The current 69b1f5db9 revision is running
[CI](https://github.com/OPCFoundation/UA-.NETStandard/actions/runs/38165826937),
[package
validation](https://github.com/OPCFoundation/UA-.NETStandard/actions/runs/38165823359),
[MCP
usability](https://github.com/OPCFoundation/UA-.NETStandard/actions/runs/38165826874),
[CodeQL](https://github.com/OPCFoundation/UA-.NETStandard/actions/runs/38165826906),
and [Images
CI](https://github.com/OPCFoundation/UA-.NETStandard/actions/runs/38165826884).
These runs are not claimed as completed results.

**Earlier completed verification:** all seven default-TFM package
archives were inspected after the package rename: exact IDs, 27 clean
DLLs, correct dependencies/readmes/licenses, and unchanged pinned model
hashes. [CI on
c75ec4fa6](https://github.com/OPCFoundation/UA-.NETStandard/actions/runs/38142519760)
passed, including the formerly failing Linux batch and required summary.
Its unchanged coverage gate passed at **88.56% lines, 78.96% branches,
84.28% patch** against the 75% patch floor. The later
bounded-Publish-helper regressions reproduced 13 failures before the fix
and passed afterward; the full mainline triggering fixture passed 67
tests on each of net10/net48. These are historical results, not current
full-matrix results.

<details>
<summary>Executed API naming validation commands and outcomes</summary>

Run from the worktree root using the established artifact trees.
Dependency builds were performed before the no-build full net10 run; the
focused loader/hosting selection was rebuilt and rerun after the final
null-guard adjustment.

```powershell
$files = 'C:\Users\mschier\.copilot\session-state\deb2ba10-b0e9-4a44-8783-8331bc850814\files'
$env:DOTNET_CLI_USE_MSBUILD_SERVER = '0'
$env:TEMP = 'C:\Temp\mv-deb2\followup'
$env:TMP = $env:TEMP

# Passed: 572; Failed: 0; Skipped: 1.
dotnet test tests\Opc.Ua.Vision.Tests\Opc.Ua.Vision.Tests.csproj -c Release -f net10.0 --no-build --no-restore --artifacts-path artifacts\vision-followup -p:CustomTestTarget=net10.0 -p:UseSharedCompilation=false -m:1 -nr:false --logger 'console;verbosity=minimal' --logger trx --results-directory "$files\vision-api-names-net10" -- 'NUnit.WorkDirectory=C:\Temp\mv-deb2\followup'

# Passed: 9; Failed: 0; Skipped: 0, on the final source.
dotnet test tests\Opc.Ua.Vision.Tests\Opc.Ua.Vision.Tests.csproj -c Release -f net10.0 --no-restore --artifacts-path artifacts\vision-followup -p:CustomTestTarget=net10.0 -p:UseSharedCompilation=false -m:1 -nr:false --filter 'FullyQualifiedName~MachineVisionModelTests|FullyQualifiedName~StandalonePartTwoRegistrationUsesOneOwnerAndOneModelProvider|FullyQualifiedName~StandalonePartTwoRequiresRegistrationAndRejectsCompetingOwnership' --logger 'console;verbosity=minimal' --logger trx --results-directory "$files\vision-api-names-final-model" -- 'NUnit.WorkDirectory=C:\Temp\mv-deb2\followup'

# Passed: 198; Failed: 0; Skipped: 0.
dotnet test tests\Opc.Ua.Tools.Tests\Opc.Ua.Tools.Tests.csproj -c Release -f net10.0 --no-restore --artifacts-path artifacts\vision-followup -p:CustomTestTarget=net10.0 -p:UseSharedCompilation=false -m:1 -nr:false --filter 'FullyQualifiedName~VisionMachineToolsTests|FullyQualifiedName~McpHostBuilderTests|FullyQualifiedName~McpToolProfileCompositionTests' --logger 'console;verbosity=minimal' --logger trx --results-directory "$files\vision-api-names-final-mcp" -- 'NUnit.WorkDirectory=C:\Temp\mv-deb2\followup'

$env:TEMP = 'C:\Temp\mv-deb2\followup48'
$env:TMP = $env:TEMP

# Passed: 515; Failed: 0; Skipped: 0.
dotnet test tests\Opc.Ua.Vision.Tests\Opc.Ua.Vision.Tests.csproj -c Debug -f net48 --no-restore --artifacts-path "$files\solution-net48-artifacts" -p:CustomTestTarget=net48 -p:UseSharedCompilation=false -m:1 -nr:false --logger 'console;verbosity=minimal' --logger trx --results-directory "$files\vision-api-names-net48-final" -- 'NUnit.WorkDirectory=C:\Temp\mv-deb2\followup48'
```

Opt-in long-running/stress/durable tiers have not been rerun locally;
the unqualified **all tests** checklist item remains open. Original
implementation matrix counts are not presented as current results.

</details>

## Related Issues

- Fixes #4732.

The requester reviewed and approved the implementation plan and
subsequent packaging/API decisions in the development session. The
tracking issue records the design; maintainer architectural
discussion/sign-off there is still pending. This PR remains a draft.

## 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.
- [x] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [x] I have added all necessary documentation.
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed.
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [x] I have addressed **all** PR feedback received.
2026-10-12 07:01:27 +00:00
romanett 0716603543 Fix concurrent session creation on a shared ClientChannelManager channel (#4739)
# Description

Sessions created concurrently through one `ClientChannelManager` against
the same endpoint (`Session.CreateAsync(IClientChannelManager, ...)`)
failed with `BadSecureChannelClosed 'The managed transport is closed.'`,
and the log showed `Background operation OpenChannel started by
ClientChannelManager failed. Channel is Closed.` Creating the same
sessions one after another worked.

## Root cause

A caller that finds an existing shared entry gets a lease right away,
even while the entry's creator is still opening the transport:

1. That session's `OpenAsync` called
`lease.Entry.SnapshotClientCertificate()` before any transport was
installed and threw *"The managed transport is closed."*
2. The failed session disposed its lease. The refcount dropped to 0 and
the entry was torn down.
3. The creator's in-flight `OpenInitialAsync` then found the entry
closing and threw *"Channel is Closed."* Every session attached to that
entry failed.

## Changes

- **`Session.OpenAsync`** waits until the shared entry has an installed
transport (new `ChannelEntry.WaitForTransportAsync`) before it reads the
certificate. The signal fires when the initial open installs a
transport, or when a reconnect that started during the initial open
does. It does not wait for `Ready`: `OpenCoreAsync` also runs for
session recreate inside a reconnect cycle, and the cycle installs the
transport before participants reactivate.
- **`ClientChannelManager.GetCoreAsync`**: the creator holds no lease
while it opens the entry, so other participants' releases can close the
entry under the open. The creator now retries on a fresh entry instead
of failing. When the initial open fails on its own, the entry records it
(`InitialOpenFaulted`, set under the entry lock at failure time, because
the later lifecycle state cannot tell the two cases apart). A faulted
open, or one closed by manager disposal, still fails the creator.

## Tests

-
`SharedChannelConcurrentSessionTests.SessionAttachedWhileChannelOpensWaitsForTransportAsync`
(Client.Tests): holds the creator's transport through gated channel
bindings until a second session has attached to the opening entry, then
checks that both sessions open and share the channel. Fails
deterministically without the fix.
-
`ClientChannelManagerManagedTests.WaitForTransportWaitsForReconnectStartedDuringInitialOpenAsync`
and `CreatorDoesNotRetryInitialOpenThatFaultedAsync` (Core.Tests): cover
the reconnect-during-open wait and the no-retry-on-fault path.
-
`SharedChannelConcurrentSessionTests.ConcurrentlyCreatedSessionsShareOneChannelAsync`
(Client.Tests): creates 20 sessions concurrently through one manager
against the reference server. It checks that they share one channel with
refcount 20 and that every session can read. Fails without the fix.
-
`ClientChannelManagerManagedTests.CreatorRetriesWhenLeaseReleasedDuringInitialOpenAsync`
(Core.Tests): holds the initial open with a mock transport, then
attaches and releases a second lease during it. It checks that the
creator gets a lease on a fresh entry. Fails without the fix with
"Channel is Closed."

Verification:
- **Local runs (net10.0, sequential):** Opc.Ua.Client.Tests 2778 passed;
Opc.Ua.Sessions.Tests 851 passed, 379 skipped; Opc.Ua.Core.Tests 6241
passed, 88 skipped.
- **net48:** Client.Tests and Core.Tests compile cleanly.
- **ServerLoadHarness repro:** `--client shared --sessions 20
--connect-parallel 4/20` now connects every run. The same build without
the fix still fails.

Note: when the creator's open is cut short this way, the `OpenChannel`
background failure is still logged at `fail` level even though the
creator now recovers.

## Related Issues

- None filed yet. Found while load-testing the shared-channel client
with the ServerLoadHarness.

## Checklist

- [x] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [x] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [x] I have added all necessary documentation.
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed.
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [ ] I have addressed **all** PR feedback received.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 07:04:41 +02:00
romanett f5062be349 Complete each pooled service response at most once (#4729) (#4736)
## Proposed changes

Fixes the cross-talk part of #4729 on master.

`ServiceResponsePooledValueTaskSource` comes from one pool shared by the
whole process, and `ManualResetValueTaskSourceCore.SetResult` never
checks the version token. A second completion of an
`EndpointIncomingRequest` therefore either threw
`InvalidOperationException` or, once the source had been read and
recycled, answered whichever request rented it next. That request could
belong to another server instance in the same process.

master's request queue (Channel + `FairRequestQueue`) already completes
each request exactly once. But `ServerBase.ScheduleIncomingRequest` and
`ProcessRequestAsync` are virtual, so a single override that completes
and then throws (the `ProcessAsync` catch completes again) was enough to
corrupt unrelated requests.

- Each rental of the pooled source now gets a lease number, which is
never reused for a later rental of the same instance.
- `EndpointIncomingRequest` captures the lease when it is constructed
and completes through `TrySetResult(lease, response)`. That is a single
CAS on the pending lease, so only the first completion of the current
rental is accepted. Late or stale completions are discarded and can
never reach the next owner.
- `SetResult`/`SetException`, which had no callers outside this path,
are replaced by `TrySetResult`.

The master378 backport #4738 also fixes the double completions in the
old `RequestQueue` itself (snapshot in `Dispose`, and enqueue followed
by a throw).

## Tests
- `ServiceResponsePooledValueTaskSourceTests`: a second completion of
the same rental is ignored, and a late completion after the source is
recycled does not complete the next rental. Both use a private pool, so
the result is deterministic.
-
`EndpointIncomingRequestTests.ProcessAsync_ScheduleCompletesThenThrows_KeepsFirstCompletionAsync`:
a scheduler that completes the request and then throws. Without the fix
it fails with `InvalidOperationException`.
- `Opc.Ua.Core.Tests` Stack.Server + Stack.Tcp (net10.0): 1871 passed, 0
failed.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 05:31:04 +02:00
romanett 2c431ad728 Client/transport: one recv per small chunk, ManagedSession lock fast path, write coalescing (#4737)
Follow-ups from the "Not changed" list of #4720 (client per-request
allocation and CPU): three changes that the paired A/B shows a gain for,
plus a write-up of the fourth (discarded `ResponseHeader`), which is not
changed.

## Results

Paired A/B, each change against the commit before it: 4 alternating
rounds, medians, 20 sessions, 24-core machine shared with other
sessions. Client = the harness process, server = the reference server
child. Allocated bytes per operation are deterministic; throughput and
latency carry the run-to-run noise noted below.

| Change | Scenario | Throughput | p50 | p90 | Client CPU / op | Client
alloc / op | Server CPU / op |
|---|---|---|---|---|---|---|---|
| Receive staging | Read, 1 value | +4% | **−9%** | **−13%** | ≈ | ±0 |
≈ |
| | Read, 1 value, SignAndEncrypt | +6% | −6% | −11% | ≈ | ±0 | ≈ |
| | Read, 100 values | +2% | −4% | ≈ | ≈ | ±0 | ≈ |
| | Data changes | ≈ | ≈ | ≈ | ≈ | ±0 | ≈ |
| ManagedSession lock | Read, 1 value, ManagedSession | ≈ | ≈ | ≈ | ≈ |
**−125 B** (2632 → 2507) | ≈ |
| | Same, inflight 8 | ≈ | ≈ | ≈ | ≈ | −133 B | ≈ |
| Write coalescing | Read, 1 value, 20 sessions on one shared channel,
inflight 4 | **+13%** | **−13%** | −3% | −6% | ±0 | −4% |
| | Same, inflight 1 | +20% ¹ | −8% | ¹ | −9% | ±0 | −13% |
| | Read, 1 value, inflight 8 (one channel per session) | +2% | −4% |
−6% | ≈ | ±0 | ≈ |
| | Read, 100 values, inflight 8 | +3% | −4% | −8% | ≈ | ±0 | ≈ |
| | Data changes | ≈ | ≈ | ≈ | ≈ | ±0 | ≈ |

≈: within run-to-run noise (about ±5% for CPU per op here).
¹ Rounds ranged from −42% to +32%; the inflight-4 runs were better in
all four rounds.

## Changes

- **`TcpByteTransport` receive**: `ReceiveChunkAsync` read the 8-byte
chunk header and then the body, two `recv` calls per chunk. It now waits
for data with a small staging buffer (8 KB, allocated on the first
receive, not from the `BufferManager` budget). One receive returns the
header and, when the chunk is small and already there, the whole chunk;
bytes of following chunks are kept for the next call. A larger chunk is
completed with a receive into the rented buffer, as before.
- The receive buffer is still rented only after the header has been
validated (message type, size against the negotiated limit), so an idle
peer holds none of the budget.
- A receive cancelled while its bytes are in the staging buffer keeps
them for the next call; on .NET Framework the staging receive takes no
token, so cancellation is observed once data arrives.
- A receive that fails while reading the body of a chunk larger than the
staging buffer has consumed part of the chunk; later receives then fail
with `BadTcpInternalError` rather than read body bytes as a header.
Before this PR such a failure lost the header and body bytes silently.
The channel cancels receives only when it closes.
  - Server listener channels use the same transport.
- **`ManagedSession` service calls**: each call was an async wrapper
(await the reader lock, await the inner call), one more state machine
box per request.
- When services are available and no writer holds or waits for the lock,
the reader lock is taken synchronously
(`AsyncReaderWriterLock.TryEnterReader`). It is released by a cached
continuation on the inner call's task, registered before the caller's
await, so the lock is free again when the caller resumes.
- Otherwise the call waits for the lock as before, so reconnect and
failover still block new calls and drain the ones in flight.
- The remaining 88 B per call is the task's continuation list (two
continuations).
- **Write coalescing** (`UaSCUaBinaryChannel`): when more writes for the
same transport are queued behind the one being sent, the writer takes up
to 16 of them and sends their segments in one vectored send.
- Each write is then reported on its own, in order, with its size. A
failed send is reported for every write in it, and their buffers are
returned.
- Writes still never start on the caller's stack, and one writer sends
one batch at a time, so chunk order is unchanged.
- Only byte-stream transports opt in (`TcpByteTransport`, also through
the server's admission lease, via the internal `IUaSCChunkCoalescing`).
WebSocket and capture decorators keep one send per chunk.

### Behaviour notes
- Each TCP connection that has received holds an 8 KB staging array
(pinned on .NET 5+), outside the `BufferManager` budget. The previous
transport held an 8-byte header array.
- `ManagedSession` service calls: if the inner call completes while the
release continuation is being registered, the reader lock is released on
the thread pool shortly after, instead of before the caller resumes.
- Concurrent `ReceiveChunkAsync` calls on one `TcpByteTransport` now
throw `InvalidOperationException`. The channel has a single receive
loop, and interleaved receives on a stream were never valid.

## Not changed: discarded `ResponseHeader`
Every generated structure's `Initialize()` constructs its
structure-typed fields: `new ResponseHeader()` (112 B), `new
RequestHeader()` (104 B). The decoders always create the instance (`new
T()` or the activator) and then assign every field in `Decode`, so the
default is thrown away. That is 4.6% of the client's 2.4 KB per Read and
about 3% on the server, which discards a `RequestHeader` per decoded
request and a `ResponseHeader` per response it constructs.
`PublishResponse` also discards a default `NotificationMessage`. Options
considered:
- **Lazy getter** (`get => m_x ??= new X()`): `IsEqual`/`GetHashCode`
compare fields, so a never-read default (null) would no longer equal an
explicit default. Two threads reading an unset header could get
different instances. Pooled instances reset through `Initialize()`
anyway.
- **Decode-only constructor** called by the activator: only the plain
`Class`/`DerivedClass` templates assign every field in `Decode`, not
optional-field or union types. A derived type's constructor must chain
to a base built by an older generator in another assembly, which lacks
it (the same problem `ForeignDerivedPooledExtensionClass` works around).
It also needs a new public marker type in `Opc.Ua.Types`.
- **Decode into the existing instance**: needs a new `IDecoder` member
in all decoders. It also changes behaviour when a caller decodes into an
object whose header it still references.

Each touches every generated type for about 100 B per message, so this
is left as is.

## Harness
- `--client shared`: classic sessions whose secure channels come from
one `ClientChannelManager`.
- Created sequentially (`--connect-parallel`, default 1). Concurrent
`Session.CreateAsync` calls on one manager fail with
`BadSecureChannelClosed`, a separate bug.
- 20 sessions on one channel reach about 45–70k req/s, against about
140k on 20 channels.
- `ab.sh`: `CANDIDATE=` selects the candidate build.

## Testing
- New: `TcpByteTransportTests`: chunks sent together, a chunk split
across sends (inside the next header), a chunk larger than the staging
buffer, cancellation keeping staged bytes.
`UaSCBinaryChannelWriteQueueTests`: coalesced in order (1 + 16 + 16 + 7
sends), framing transport not coalesced, a failed batch reported for
every write and later writes still sent. `AsyncReaderWriterLockTests`:
`TryEnterReader` against a writer, the reader held until the operation
completes and released on failure and on a completed operation, the
awaiter resuming after the release.
- Before the rebase onto master, net10.0, run sequentially: Core
Stack+Security 4689 passed, 1 failed
(`ClientBaseTests.ActivityTraceFlags_Metrics_ShouldRecordMetricsOnly`, a
meter-listener flake fixed on master by #4708; passes 3/3 in isolation);
Client 2768; Sessions 851 (379 skipped); Server 8398; Subscriptions 643.
- After the rebase: Core Stack+Security 4811 passed; Client 2781; Stress
Contract+Integration 45 passed; all 0 failed. Sessions, Server and
Subscriptions were not rerun on the new base.
- net48 build of `UA.slnx`: clean (0 warnings, 0 errors); `dotnet format
whitespace` clean.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 05:30:29 +02:00
Marc Schier 5e71e2243c Test that a secured connection refuses its JSON metadata announcement (#4724)
# Description

#4712 makes a connection configured for message security send its
DataSetMetaData announcement through the connection's security step, so
the announcement is never sent unsecured. Its tests cover the UADP
announcement. This adds the JSON case.

The UADP security wrapper cannot protect a JSON message, so a connection
whose WriterGroup uses `SignAndEncrypt` must refuse the JSON metadata
announcement rather than publish the DataSet layout in plaintext.
`MetaDataPublisherTests.JsonAnnouncementOfASecuredWriterGroupIsRefusedAsync`
checks that nothing is published on the metadata topic and that the
refusal is counted in `EncryptionErrors`. `PubSubApplication.StartAsync`
returns after the initial announcement was sent or refused, so the check
needs no wait.

The test covers the fail-closed branch of
`PubSubConnection.EncodeOutboundAsync` and the refused-send return of
`SendNetworkMessageAsync`, two of the three changed lines the
patch-coverage report of #4712 listed as uncovered. Test-only change.

Review: the test double `RecordingTransport` guards its recorded sends
with a private `System.Threading.Lock` and exposes `SendCount` and
`GetSendsSnapshot()`, so the tests no longer lock the list itself
(b7e0ba4b1).

### Validation

| Command | Result |
| --- | --- |
| `dotnet test tests/Opc.Ua.PubSub.Tests -f net10.0 --filter
"Name~JsonAnnouncementOfASecuredWriterGroupIsRefusedAsync"` with the
fail-closed branch of `EncodeOutboundAsync` temporarily removed | Fails:
`Expected: no item String containing "/metadata/"`, but the announcement
was published on `opcua/json/metadata/p17/7/42` |
| `dotnet test tests/Opc.Ua.PubSub.Tests -f net10.0` | 1499 passed, 1
skipped |
| `dotnet test tests/Opc.Ua.PubSub.Tests -f net48 --filter
FullyQualifiedName~MetaDataPublisherTests` | 10 passed |
| On the merged head with the lock change (07142f7b3):
`MetaDataPublisherTests` on net10.0 (3 runs) and net48; the full
`Opc.Ua.PubSub.Tests` on net10.0 | 10 passed each; 1498 passed, 2
skipped (the UDP multicast loopback tests, which skip when loopback is
not available) |

## Related Issues

- Follow-up to #4712.

## Checklist

- [ ] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [x] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [x] I have added all necessary documentation.
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed.
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [ ] I have addressed **all** PR feedback received.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: marcschier <11168470+marcschier@users.noreply.github.com>
2026-10-11 14:04:59 +02:00
romanett b25429bccc Client: cut per-request allocation and CPU in the channel and session; measure the client in the load harness (#4720)
Follows #4707 (replaces #4714, which closed when the #4707 branch was
deleted). Profiling the client process under load showed per-request
allocation spread over the channel write path, the response wait,
per-chunk header encoders and an AES instance per message, plus a stack
walk per service call. This PR removes them and extends the load harness
to measure the client.

## Results

Paired A/B against the #4707 head, the server work now on master (3
alternating rounds, medians, 20 sessions unless noted, workstation GC on
both sides, 24-core machine). Client = the harness process, server = the
reference server child.

| Scenario | Throughput | p50 | p90 | Client CPU / op | Client alloc /
op | Server alloc / op |
|---|---|---|---|---|---|---|
| Read, 1 value | **+21%** (141k → 170k req/s) | −9% | −11% | −13% |
−46% (4.5 → 2.4 KB) | −20% |
| Read, 1 value, ManagedSession | **+18%** | −9% | −12% | −18% | −44% |
−20% |
| Read, 1 value, SignAndEncrypt | **+28%** | −14% | −20% | −24% | −52% |
−26% |
| Read, 1 value, 200 sessions | **+29%** | −11% | −17% | −17% | −46% |
−20% |
| Read, 100 values | +5% | −2% | −3% | −6% | −44% (15.6 → 8.8 KB) | −2%
|
| Read, 100 values, SignAndEncrypt | +8% | −7% | −2% | −2% | −46% | −3%
|
| Write, 100 values | ±0 | ≈ | ≈ | −14% | **−63%** (10.8 → 4.0 KB) | −2%
|
| Browse (Server object) | **+12%** | −7% | −11% | −19% | −17% | −12% |
| Data changes, classic fast callback | ±0 ¹ | ≈ | ≈ | −21% | −10% | −5%
|
| Data changes, classic item events | ±0 ¹ | ≈ | ≈ | ≈ ² | −7% | −5% |
| Data changes, ManagedSession (V2) | ±0 ¹ | ≈ | ≈ | ±0 | −5% | −5% |
| Data changes, SignAndEncrypt | ±0 ¹ | ≈ | ≈ | −22% | −14% | −7% |

¹ Throughput is bounded by the writer rate; latency is dominated by the
100 ms publishing interval. Per notification the client uses about 2 µs
and 170–260 B.
² The client uses 0.2–0.4 cores in the subscription scenarios; the CPU
per notification is within run-to-run noise there.

Client GC pause time in the window drops 38–66% in the Read and Write
scenarios (17% for Browse). About 70% of the remaining client CPU per
small request is kernel time in loopback socket send/receive.

## Changes

- **Activity source**: `Session`, `SessionClientBatched`, the classic
`Subscription` and `ClassicSubscriptionEngine`, and the TCP/HTTPS client
channels resolve their `ActivitySource` once.
`TelemetryExtensions.StartActivity` called
`Assembly.GetCallingAssembly()` (a stack walk) on every service call,
~8% of client CPU.
- **Channel write queue** (`UaSCUaBinaryChannel`, client and server): an
ordered queue drained by one thread-pool writer replaces the per-write
`ContinueWith`/`Unwrap`/trailing continuation chain. The writer is
queued to the issuing thread's local pool queue, like the task
continuation was (queuing it globally cost ~15 µs of p50 in a first
iteration). Successful writes are reported inline instead of through
`Task.Run`; failed writes still go through the pool. The client channel
enters its gate only for a failed write instead of on every write
completion.
- **`TcpByteTransport`**: no linked `CancellationTokenSource` when the
caller's token cannot be cancelled (the channel always passes `None`); a
single-chunk `BufferCollection` uses the `ReadOnlyMemory` send instead
of the vectored `Task<int>` send; `PoolingAsyncValueTaskMethodBuilder`
on .NET 6+ for the receive/send state machines; no closure per vectored
send.
- **`BinaryEncoder.GetNodeIdEncoding`**: `Convert.ToUInt32(uint,
IFormatProvider)` bound to the `object` overload and
`Convert.ToByte(enum, ...)` boxed the enum, two boxes per encoded
NodeId. 44% of the client allocation of a 100-value Read; the server
encodes NodeIds the same way.
- **Symmetric chunk headers**: the 24 header bytes are written and read
with `BinaryPrimitives` instead of a `MemoryStream` +
`BinaryEncoder`/`BinaryWriter` (send) and `BinaryDecoder`/`BinaryReader`
(receive) per chunk. A chunk too short for a field still gets the
decoder's `BadDecodingError`.
- **AES-CBC**: `ChannelToken` keeps one `Aes` per direction, used with
the one-shot `EncryptCbc`/`DecryptCbc` on .NET 6+ (net48 reuses the
instance and creates the transform). Previously every message created an
`Aes`, imported the key (`BCryptImportKey`) and created a transform,
~15% of client CPU with SignAndEncrypt. The instance is taken out of the
token while in use, so concurrent callers never share one. The public
`CryptoUtils` overloads are unchanged.
- **`ChannelAsyncOperation`**: `SendRequestAsync` waits on an internal
task-free completion source instead of `EndAsync`
(`TaskCompletionSource` + `Task` + `WaitAsync` promise + state machine);
`EndAsync` registers cancellation instead of `WaitAsync(ct)`; the
timeout timer uses a static callback; completing with `default(T)`
shares a box.
- **Session keep alive**: every good response called `Timer.Change`
under the session lock, taking the timer queue lock. It now records the
time; the timer callback defers itself to one interval after the last
response, which is when the per-response reset made it fire.

### Behaviour notes
- `TcpByteTransport.SendChunkAsync`/`ReceiveChunkAsync` return pooled
`ValueTask`s on .NET 6+: like any `ValueTask` they must be awaited once.
- A successful write's `HandleWriteComplete` now runs on the writer
instead of a separate pool work item. Overrides outside the stack that
block in it delay later writes on that channel.
- The write itself no longer flows the caller's `ExecutionContext`
(`Activity.Current` is not set inside the transport send).

## Harness (`perf/`)
- `client-process:` and `client-per-op:` result lines; `--trace-side
client` records the harness itself.
- `--client managed` (ManagedSession, V2 engine) and `--callback item`
(classic per-item events).
- Classic fixture sessions no longer request diagnostics in every
response (the server otherwise sends a `DiagnosticInfo` per
notification).
- `ab.sh`: `SCENARIOS`/`ROUNDS`/`OUT`, CRLF tolerant, defaults before
the scenario (a `--sessions 200` scenario silently ran with 20 before);
`client-scenarios.txt`; `ab-summary.ps1` reports client and server
medians.
- README notes two profile traps: `Monitor.Enter_Slowpath` in the
sampled CPU profile without matching contention events (46 in 10 s
here), and allocation ticks over-attributing small objects allocated
after large arrays.

## Testing
- New: `UaSCBinaryChannelWriteQueueTests` (order, one send at a time,
never on the caller's stack, failures reported and later writes still
sent, concurrent writers), `ChannelAsyncOperationWaitTests` (late/early
completion, faults, cancellation, disposal, continuation off the
completing thread, 2000-iteration completion/cancellation race,
timeout), `CryptoUtilsAesCacheTests` (cached = fresh cipher text across
IVs, round trips, concurrent users, token disposal),
`TcpByteTransportTests` single/multi-buffer and cancellable sends,
`SessionKeepAliveTests` keep-alive read exactly one interval after the
last response.
- After merging master: Core Stack 3571 passed, Client 2763 passed, 0
failed.
- Before merging master, net10.0: Core Stack+Security 4680 passed;
Client 2763; Sessions 849 (379 skipped); Server 8391; Subscriptions 643;
all 0 failed.
- Stress Contract+Integration: 5 failures, the same 5 on the unchanged
#4707 head (`CertRotationContractTests`, `GateAndBypassTests`,
`LeaseLifecycleTests`, `RetryBudgetEnforcementTests` reflection seam,
`CertRotationLiveTests`), not caused by this PR.
- net48 build of `UA.slnx` clean; `dotnet format whitespace` clean.

## Not changed (follow-ups)
- Two `recv` calls per chunk (8-byte header, then body). Merging them
needs read-ahead with carry-over, which the transport avoids so that
idle peers do not hold receive buffers.
- `ManagedSession` service wrappers add an async state machine and a
reader lock per call (~200 B).
- Generated responses allocate a discarded `ResponseHeader`
(generator-wide, as noted in #4707).
- Decoded `MonitoredItemNotification` objects and the V2
`DataValueChange[]` per message dominate notification allocation
(`WithPoolNotifications` pools the latter).
- Coalescing queued writes on one channel into one vectored send would
cut syscalls under concurrent requests on a shared channel.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Marc <marcschier@hotmail.com>
Co-authored-by: Marc Schier <marcschier@users.noreply.github.com>
2026-10-10 20:00:33 +02:00
romanett c7fdeb6de1 Fix stress Contract/Integration failures and run them in stress-test.yml (#4718)
## Description

`Opc.Ua.Stress.Tests` with `Category=Contract|Category=Integration` had
five failures on master. The README says these categories run on every
PR, but no workflow ran them: `get-ci-matrix.ps1` excludes the Stress
project, and `stress-test.yml` only ran `ChaosTCP`. The old
`Channels.Stress` special case in `buildandtest.yml` never matched the
project name, and #4513 removed it. So the tests rotted unseen. Two of
the five failures were real product bugs.

### Product fixes

- **Session recreate after a server restart failed forever (regression
from #4503).** User-token PolicyIds are assigned by the server and are
not stable across a restart. In the failing run the client had cached
`Anonymous|20` and the restarted server offered `Anonymous|2`. The
CreateSession check added in #4503 requires an exact match, including
PolicyId, so every `RecreateInPlace` failed with
`BadSecurityChecksFailed` ("different security policy for the selected
user identity token"). The managed session then retried without end.
- `Session.OpenCoreAsync` now adopts the server's policy when only the
PolicyId differs. TokenType, SecurityPolicyUri, IssuedTokenType and
IssuerEndpointUrl must still match, so a downgrade is still rejected.
The token is restamped with the new id.
- **A certificate rotation during a reconnect reactivated every session
twice.** `ChannelEntry.ReconnectCycleAsync` only noticed the superseded
client certificate *after* it had notified participants. It then
replaced the transport and notified them again. The check now also runs
right after the transport reconnects, before participants are notified.

### Tests updated to the #4516 contracts

- `RetryBudgetEnforcementTests`: `Session.ReconnectManagedChannelAsync`
gained an optional reverse-connection parameter, which the reflection
call must supply.
- `GateAndBypassTests`: the participant callback token is cancelled once
`OnReconnectAsync` returns. The spawned worker now runs on the test
token.
- `LeaseLifecycleTests`: releasing the last lease cancels the recovery
in progress, as documented. The reconnect caller now expects
`OperationCanceledException`.

### CI

- New `Contract and Integration` job in `stress-test.yml`, with the same
gate as Chaos TCP: nightly, manual, and PRs labelled `stress`. It takes
about 1 minute of test time.
- The README now says where these categories actually run.

### Tests

- New
`SessionIdentityReviewTests.CreateAdoptsRenumberedUserTokenPolicyIdAsync`,
covering both a renumbered id (accepted) and a changed token security
policy (rejected). It fails without the fix.
- Each of the 5 fixed tests passed 3/3 runs in isolation. The full
`Contract|Integration` filter passed 45/45 twice in Debug and once in
Release.
- Related tests also pass: Core.Tests channel tests 587/587,
Sessions.Tests channel tests 119 passed (63 skipped), Client.Tests
channel/session/reconnect tests 512/512.
- The net48 build of `UA.slnx` is clean.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Marc Schier <marcschier@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-10-10 08:06:30 +02:00