Commit Graph
2965 Commits
Author SHA1 Message Date
Marc 0dfae76f41 Add host-dispatched UAFX endpoint lifecycle 2026-10-27 06:15:50 +01:00
Marc 1d6db20b1d Implement Data Channel PubSub and UAFX foundations 2026-10-26 21:44:12 +01:00
Marc d3004c4084 Validate complete UAFX configuration before flow creation 2026-10-24 18:01:20 +02:00
Marc f9c2ddf142 Add pinned UAFX Part 81 generated model contracts 2026-10-24 17:14:25 +02:00
Marc 9ce8192ebe Cover UADP source isolation and rollover and align Data Channel guides 2026-10-24 16:25:51 +02:00
Marc 97a2fe01fc Synchronize bundled async identity migration guidance 2026-10-24 16:04:29 +02:00
Marc c5718a0b40 Merge master preserving Data Channel routing and metadata isolation 2026-10-24 15:58:53 +02:00
Marc cd1b3bcaa2 Implement revised Data Channel bindings and address PR feedback 2026-10-24 15:35:52 +02:00
Wolfgang ColsmanandMarc Schier f3980f9e72 Client: managed session recovery over application-supplied reverse connections (#4883)
# Description

## Problem

With Reverse Connect (OPC 10000-6, 7.1.3) the server opens the
transport connection and sends `ReverseHello`; the client never dials.
To
re-establish a lost session (OPC 10000-4, 6.7: a new SecureChannel, then
`ActivateSession` for the existing session, then republish or
`TransferSubscriptions`) the client therefore needs a fresh
server-initiated
connection for every recovery attempt.

A `ManagedSession` can obtain such connections only from a
`ReverseConnectManager`:

- The `ITransportWaitingConnection` passed to
`ManagedSession.CreateAsync` is
single-use and consumed by the initial connect. Without a manager every
later
recovery fails with `BadSecureChannelClosed` ("A fresh reverse
connection is
  required.") until the reconnect policy gives up.
- Recovery calls `ReverseConnectManager.WaitForConnectionAsync`
directly. The
manager owns its listeners and has no extension point, so connections an
  application accepts itself cannot be fed into it.

Applications that accept reverse connections themselves cannot use the
managed
recovery today. Typical reasons are admission decisions before the OPC
UA
handshake (source network, the server URI and endpoint URL from
`ReverseHello`), a bounded number of held connections per server, or
connections accepted by another process and handed over. Such an
application
has to drive `ManagedSession.ReconnectAsync(ITransportWaitingConnection,
...)`
itself and install a reconnect policy that refuses every attempt of the
managed
loop, which duplicates the backoff and budget of the managed loop and
races its
keep-alive based failure detection.

## API

```csharp
namespace Opc.Ua.Client
{
    public interface IReverseConnectionSource
    {
        ValueTask<ITransportWaitingConnection> WaitForConnectionAsync(
            Uri endpointUrl, string? serverUri, CancellationToken ct);
    }

    public class ReverseConnectManager : IDisposable, IAsyncDisposable, IReverseConnectionSource
    {
        // explicit implementation; delegates to WaitForConnectionAsync(Uri, string?, CancellationToken)
    }

    public sealed record ManagedSessionOptions
    {
        public IReverseConnectionSource? ReverseConnectionSource { get; init; }
    }

    public sealed class ManagedSessionBuilder
    {
        public ManagedSessionBuilder UseReverseConnect(IReverseConnectionSource source, Uri serverUri);
    }
}
```

## Behaviour

1. Initial connect: an explicit initial `connection` argument is still
used
first. Otherwise the session awaits the source (after the connect gate
admitted the attempt, like the manager path). When the endpoint is
refreshed
before connecting, discovery runs over one connection from the source
and the
   session over the next one, as with a manager.
2. Recovery: every place that awaits the manager during recovery
(reconnect,
in-place recreate, network-redundancy alternate, endpoint refresh,
explicit
`ReconnectAsync` without a connection, mirrored-session reactivation)
awaits
the source instead. A recovery attempt that runs under the reconnect
budget
waits at most for the remaining `MaxTotalReconnectTime`; when the budget
runs
out the source's token is cancelled and the attempt fails with
`BadTimeout`,
so the state machine leaves `Reconnecting` as it does after a failed
dial. A
source that throws ends that attempt like a failed dial; the next
attempt
   follows the policy's delay.
3. Precedence: `ManagedSession.CreateAsync(options, ...,
reverseConnectManager)`
with `options.ReverseConnectionSource` set throws `ArgumentException`.
The two
   `UseReverseConnect` builder overloads replace each other.
4. Ownership: the session closes a connection it received after the wait
was
cancelled (closing the session, disposal, budget exhausted), so a source
does
not have to track abandoned connections. A source that returns `null`
fails
the attempt with `BadUnexpectedError` instead of falling back to an
outbound
   connection.
5. Sessions without the option behave as before; the manager paths are
unchanged (the manager's initial connect still goes through the session
   factory's manager overload).

## Tests


`tests/Opc.Ua.Client.Tests/Session/ManagedSessionReverseConnectionSourceTests.cs`
(unit, no server):

- `CreateAsyncRejectsASourceCombinedWithAManagerAsync`
- `BuilderUseReverseConnectWithSourceSetsTheSourceAndServerUri`
- `BuilderUseReverseConnectWithAManagerReplacesTheSourceAsync`
- `BuilderUseReverseConnectRejectsNullArguments`
- `ReverseConnectManagerWaitsThroughTheSourceInterfaceAsync`
- `CancelledConnectCancelsTheSourceAndClosesALateConnectionAsync`


`tests/Opc.Ua.Sessions.Tests/ManagedSessionReverseConnectionSourceTests.cs`
(in-process reference server that keeps dialing the client's reverse
connect
listener; the source wraps that listener's `ReverseConnectManager` and
records
every call):

- `InitialConnectAwaitsTheSourceOnceAsync`
- `RecoveryReactivatesTheSessionOverAConnectionFromTheSourceAsync`:
closes the
  transport under the SecureChannel; the session reactivates the same
  `SessionId` over a new connection from the source, one source call per
attempt, and the subscription continues with higher sequence numbers
(not
  recreated).
- `EachRecoveryAttemptAwaitsTheSourceOnceFollowingThePolicyAsync`: the
source
  fails the first two recovery calls; exactly four calls, spaced by the
  policy's delay.
- `ASourceThatNeverYieldsEndsRecoveryWhenTheBudgetIsExhaustedAsync`: one
wait
  is cancelled when the 3 s budget is used up and the session reaches
  `Disconnected`.
- `DisposingWhileRecoveryWaitsCancelsTheSourceAsync`
- `ReverseConnectManagerRecoveryIsUnchangedAsync`: regression for the
manager
  path.

## Compatibility

Additive only: a new interface, a new init-only option, a new builder
overload
and an explicit interface implementation on `ReverseConnectManager`. No
existing signature changes. `IManagedSessionFactory` is unchanged;
callers of
`ConnectAsync(endpoint, configure)` can call `UseReverseConnect(source,
...)` in
`configure`.

## Related Issues

- No tracking issue; the problem and the design are described above.

## 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. (Run
locally: the affected test classes on net48 and net10.0 and the whole
Opc.Ua.Client.Tests project on net10.0; not the whole UA.slnx.)
- [ ] 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: Marc Schier <marcschier@users.noreply.github.com>
2026-10-23 19:41:32 +00:00
Marc Schier 5d6c10a6e9 Align WoT projections with Binding and Connectivity specs (#4877)
# Description

Align WoT Binding, Connectivity, and required Projections support with
the merged official specification baseline.

Projection-local affordances use ordinary TD/TM conversion and Binding
planning. The canonical View retains its identity and organizes
converter-resolved local/imported Nodes without cloning imported Nodes.
Generated local identities are resource-scoped; authored identities are
preserved. Security/schema closure retains projection ownership. Counts
include local Binding-owned Nodes plus View-owned Nodes across dry-run,
commit, unchanged refresh and restart. Runtime configuration forwards
the converter-resolved active-root namespace for multi-model closures.

Recovery rejects absent/inconsistent committed canonical View evidence
rather than resetting publication. Local+imported recovery preserves
committed membership/digest when desired inputs have changed. Fixtures
use short isolated storage roots for net48/MAX_PATH.

Security names use exact UTF-8 percent encoding; dependency-support
names retain base64url. OPC UA forms with omitted content type select
the registered octet-stream codec. Historical fixture bytes remain
unchanged with explicit current derived expectations. UA Binary
serialization remains unsupported/unclaimed: the raw-byte/text codec is
not UA Binary.

Dependency observation disclosure checks management policy, source
group/exact-Version and every captured local target
origin/group/exact-Version. Existing native role/channel-restriction
validation is reused, including namespace defaults and the virtual role
hook. Denied/unavailable targets reject the entire value with generic
access failure, no partial graph or target-identity diagnostics. A
pruned target makes remote disclosure fail closed without erasing the
stored committed graph. Both committed snapshots and completed attempts
are tested. Explicit-node tests do not establish separate
namespace-default evidence. The current capture path generates local
registry pins, not arbitrary federated/provider targets; remote
authorization is outside this implemented capability and is not claimed.

## Related Issues

- Related to #4869 only historically. Performance work is explicitly out
of current scope; this PR does not fix or close it.

## 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.
- [ ] I have addressed **all** PR feedback received.
2026-10-23 14:12:52 +02:00
Marc Schier 533937830b Restore UaLens Linux rendering (#4891)
# Description

Restore the Linux rendering checks temporarily excluded in #4822 by
updating all eight OpenUSD pins to the public **0.16.6-alpha** release.

The upstream fix keeps shared GPU surfaces where supported. When
Avalonia/Mesa cannot import external images or semaphores, it presents
completed Vulkan frames through host-memory readback instead. The
renderer, scene, and acceptance criteria remain unchanged.

- Update the complete OpenUSD package family and its verified-version
assertion together.
- Restore both live embedded-viewport workflow cases, the native
workflow smoke case, and the native viewport artifact case used by both
distribution jobs.
- Remove the temporary-exclusion notices and document the host-memory
presentation path and physical-GPU qualification limits.

All 21 resolved OpenUSD packages were independently verified on
nuget.org at source commit `0ebd61af4651457d903ed30f40965e8dcb060255`,
including the Metal and native RID graph. No private feed or replaced
DLLs are used. [Upstream
release](https://github.com/marcschier/openusd-dotnet/releases/tag/v0.16.6-alpha).

### Validation

The eight-file patch was carried unchanged onto `master` at
`97cc145811bc2d9cef11e685392696435b10df36`. The following Windows/.NET
10 checks were rerun on that base:

| Scope | Result |
| --- | --- |
| Live viewport and diagnostic workflows | Passed: 14, Failed: 0,
Skipped: 0 |
| OpenUSD package-version policy | Passed: 1, Failed: 0, Skipped: 0 |
| Viewport and rendered Vision adapters | Passed: 103, Failed: 0,
Skipped: 0 |
| Retained-camera provider and blank-frame guards | Passed: 45, Failed:
0, Skipped: 0 |

<details>
<summary>Windows commands and results</summary>

These options and reporting paths were used for each invocation:

```powershell
$options = @(
    '-c', 'Release', '-f', 'net10.0', '-p:CustomTestTarget=net10.0',
    '-p:UseArtifactsOutput=true',
    '-p:ArtifactsPath=D:\git\UA-.NETStandard_master\TestResults\ua-stack-feedback20261016',
    '-m:2'
)
$results = 'C:\Users\mschier\.copilot\session-state\abd33a2e-7139-44e1-9912-6e4451102b4e\files\openusd0166-pr-results'

# Passed: 14, Failed: 0, Skipped: 0
dotnet test tests\Opc.Ua.Lens.Workflow.Tests\Opc.Ua.Lens.Workflow.Tests.csproj @options --filter 'FullyQualifiedName~OpenUsdViewportWindowTests|FullyQualifiedName~DiagnosticCommandTests' --logger 'trx;LogFileName=workflow.trx' --results-directory $results

# Passed: 1, Failed: 0, Skipped: 0
dotnet test tests\Opc.Ua.Tools.Tests\Opc.Ua.Tools.Tests.csproj @options --filter 'FullyQualifiedName~OpenUsdPackageFamilyUsesOneVerifiedPrerelease' --logger 'trx;LogFileName=policy.trx' --results-directory $results

# Passed: 103, Failed: 0, Skipped: 0
dotnet test tests\Opc.Ua.Lens.Tests\Opc.Ua.Lens.Tests.csproj @options --filter 'FullyQualifiedName~OpenUsdViewport|FullyQualifiedName~RenderedVisionCaptureTests' --logger 'trx;LogFileName=adapters.trx' --results-directory $results

# Passed: 45, Failed: 0, Skipped: 0
dotnet test tests\Opc.Ua.Vision.Tests\Opc.Ua.Vision.Tests.csproj @options --filter 'FullyQualifiedName~RetainedSceneCameraCaptureProviderTests|FullyQualifiedName~BlankFrameGuardTests' --logger 'trx;LogFileName=capture.trx' --results-directory $results
```

The selected builds completed without new compiler/analyzer warnings.
`git diff --check` and the following formatting check passed:

```powershell
dotnet format whitespace --folder --include tests\Opc.Ua.Tools.Tests\PackageVersionPolicyTests.cs tests\Opc.Ua.Lens.Artifacts.Tests\PublishedArtifactTests.cs tests\Opc.Ua.Lens.Workflow.Tests\Companions\OpenUsdViewportWindowTests.cs tests\Opc.Ua.Lens.Workflow.Tests\Desktop\DiagnosticCommandTests.cs --verify-no-changes --verbosity quiet
```

</details>

The same patch was qualified before the base update on Ubuntu 24.04.5,
Mesa 25.2.8, and Xvfb, with no GPU device mounted:

| Scope | Result |
| --- | --- |
| Original live telemetry, picking, retained-camera capture, edit,
resize, and drain workflows | Passed: 14, Failed: 0, Skipped: 0 |
| Published Linux NativeAOT startup and native viewport | Passed: 2,
Failed: 0, Skipped: 0 |
| Freshly packed and installed Linux managed tool startup and native
viewport | Passed: 2, Failed: 0, Skipped: 0 |

Both artifact forms produced nonblank 256x192 Vulkan captures with
different decoded-pixel hashes after the scene edit, exit code zero, and
both required cleanup/smoke markers. No assertions, deadlines, or
workflow selectors were weakened.

<details>
<summary>Linux commands and results before the base update</summary>

The source was mounted read-only at `/repo`; isolated build outputs were
under `/work`, and retained results were under `/evidence`. These
commands ran in PowerShell inside the Ubuntu container:

```powershell
$options = @('-c', 'Release', '-f', 'net10.0', '-p:CustomTestTarget=net10.0', '-p:UseArtifactsOutput=true', '-p:ArtifactsPath=/work/build', '-m:2')
$env:LD_LIBRARY_PATH = '/work/build/bin/Opc.Ua.Lens.Workflow.Tests/release_net10.0:/work/build/bin/Opc.Ua.Lens.Workflow.Tests/release_net10.0/runtimes/linux-x64/native'

# Passed: 14, Failed: 0, Skipped: 0
xvfb-run --auto-servernum '--server-args=-screen 0 1600x1000x24 -dpi 96' dotnet test /repo/tests/Opc.Ua.Lens.Workflow.Tests/Opc.Ua.Lens.Workflow.Tests.csproj @options --filter 'FullyQualifiedName~OpenUsdViewportWindowTests|FullyQualifiedName~DiagnosticCommandTests' --logger 'trx;LogFileName=workflow-linux.trx' --results-directory /evidence/results -- NUnit.NumberOfTestWorkers=0 RunConfiguration.TreatNoTestsAsError=true

# Passed: NativeAOT publish, with trim/AOT diagnostics enabled
dotnet publish /repo/tools/Opc.Ua.Lens/Opc.Ua.Lens.csproj -c Release -f net10.0 -p:CustomTestTarget=net10.0 -p:UseArtifactsOutput=true -p:ArtifactsPath=/work/aotbuild -r linux-x64 --self-contained true -p:PackAsTool=false -p:PublishAotEnabled=true -p:SuppressTrimAnalysisWarnings=false -p:TrimmerSingleWarn=false -m:2 -o /work/native -bl:/evidence/native.binlog

$env:UALENS_ARTIFACT_EXECUTABLE = '/work/native/UaLens'
$env:UALENS_ARTIFACT_LOG_DIRECTORY = '/evidence/native-linux'
$env:UALENS_ARTIFACT_X11_VULKAN = 'true'
$env:UALENS_ARTIFACT_ALLOW_SOFTWARE_GL = 'false'
# Passed: 2, Failed: 0, Skipped: 0
xvfb-run --auto-servernum '--server-args=-screen 0 1600x1000x24 -dpi 96' dotnet test /repo/tests/Opc.Ua.Lens.Artifacts.Tests/Opc.Ua.Lens.Artifacts.Tests.csproj @options --filter 'FullyQualifiedName~PublishedArtifactTests' --logger 'trx;LogFileName=native-linux.trx' --results-directory /evidence/results -- NUnit.NumberOfTestWorkers=0 RunConfiguration.TreatNoTestsAsError=true

# Passed: restore, pack the selector and all three RID payloads, and isolated install
$pack = @('-p:CustomTestTarget=net10.0', '-p:UseArtifactsOutput=true', '-p:ArtifactsPath=/work/managedbuild', '-p:PublishAotEnabled=false', '-p:PublishAot=false', '-p:PublishTrimmed=false', '-p:PackAsTool=true')
dotnet restore /repo/tools/Opc.Ua.Lens/Opc.Ua.Lens.csproj -p:Configuration=Release @pack
dotnet pack /repo/tools/Opc.Ua.Lens/Opc.Ua.Lens.csproj -c Release --no-restore -p:TargetFramework=net10.0 @pack -m:2 -o /work/managed-packages -bl:/evidence/managed.binlog
dotnet tool install OPCFoundation.NetStandard.Opc.Ua.Lens --version 2.0.2-preview.100.g30f255be63 --tool-path /work/installed/tool --configfile /work/installed/NuGet.config --no-cache

$env:UALENS_ARTIFACT_EXECUTABLE = '/work/installed/tool/ualens'
$env:UALENS_ARTIFACT_LOG_DIRECTORY = '/evidence/installed-linux'
# Passed: 2, Failed: 0, Skipped: 0
xvfb-run --auto-servernum '--server-args=-screen 0 1600x1000x24 -dpi 96' dotnet test /repo/tests/Opc.Ua.Lens.Artifacts.Tests/Opc.Ua.Lens.Artifacts.Tests.csproj @options --filter 'FullyQualifiedName~PublishedArtifactTests' --logger 'trx;LogFileName=installed-linux.trx' --results-directory /evidence/results -- NUnit.NumberOfTestWorkers=0 RunConfiguration.TreatNoTestsAsError=true
```

The install configuration contained only the freshly packed
`/work/managed-packages` source; installation used an isolated tool
cache. The Linux SDK was 10.0.401, allowed by the repository's
minimum-SDK policy.

</details>

**Not run:** full `UA.slnx` suites on .NET Framework and .NET 10,
physical-GPU/display qualification, or downstream macOS/Windows
NativeAOT artifacts for this follow-up. Linux artifact results above
predate the move onto current `master`; the PR's CI must qualify the
final head. Coverage was not recollected, and no solution-wide or
current-head cross-platform CI pass is claimed.

## Related Issues

- Fixes #4890.
- Follow-up to #4822. No new application architecture or public UA API
is introduced; the compatibility repair is in the released OpenUSD
dependency.

## Checklist

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

The existing rendering tests are restored, not replaced with weaker
cases. The remaining checklist items are not asserted by the focused
local evidence above.
2026-10-23 12:12:25 +02:00
Wolfgang ColsmanandMarc Schier 97cc145811 Client redundancy: ServiceLevel-driven failover, member ServiceLevel probing and read statuses (#4884)
# Description

## Problem

OPC 10000-4, clause 6.6.2 "Server redundancy", lets a Client of a
non-transparent redundant Server set (Cold, Warm, Hot, HotAndMirrored)
connect to the Server with the highest `ServiceLevel` and fail over when
the Server in use is lost or its `ServiceLevel` leaves the Healthy
subrange (6.6.2.4.2 "ServiceLevel": 200-255 Healthy, 2-199 Degraded, 1
NoData, 0 Maintenance; 6.6.2.4.5 "Client failover behaviour"). The
managed session on `master` covers part of this:

- `ManagedSession` fails over only in the `Failover` state of
`ConnectionStateMachine`, which is entered after the reconnect policy
gives up. `DefaultServerRedundancyHandler.ShouldFailover` /
`ServerRedundancyHandlerExtensions.ShouldFailover` implement the
subrange rules, but nothing in the client calls them: a connected
session stays on a Server that reports Degraded or Maintenance until its
connection breaks. There is no public member that starts a failover
(`HandleFailoverAsync` is private).
- Members known only from `ServerUriArray` get `ServiceLevel` NoData
with `ServiceLevelKnown = false` (internal). Their `ServiceLevel` is
never read, so neither the initial connect nor `SelectFailoverTarget`
can prefer the member with the highest `ServiceLevel`.
- `ReadRedundancyInfoAsync` maps a bad status of `RedundancySupport`,
`ServerUriArray`, `RedundantServerArray` or `CurrentServerId` to an
empty or default value, and peer endpoint resolution failures are only
logged. An application cannot report which redundancy Node the Server
refused or why a member has no endpoint.

An application that must fail over on `ServiceLevel` therefore polls the
`ServiceLevel` itself, opens its own sessions to read each member, and,
lacking a failover trigger, closes the managed session and opens a new
one - losing the transfer or recreate of its subscriptions that the
managed failover performs.

## API (all additions; defaults keep the current behaviour)

```csharp
public partial class ManagedSession
{
    public Task FailoverAsync(ConfiguredEndpoint? target = null, CancellationToken ct = default);
    public ServerRedundancyInfo? RedundancyInfo { get; }           // implements IManagedSession.RedundancyInfo
}

public interface IManagedSession : ISession
{
    ServerRedundancyInfo? RedundancyInfo { get; }                  // new interface member
}

public sealed record ServerRedundancyOptions
{
    public bool ProbeMemberServiceLevel { get; init; }             // default false
    public TimeSpan? ServiceLevelMonitoringInterval { get; init; } // default null (off); must be positive when set
}

public sealed class RedundantServer
{
    public bool ServiceLevelKnown { get; init; } = true;           // was internal
    public ServiceResult? ResolutionError { get; init; }           // new
}

public sealed class ServerRedundancyInfo
{
    public IReadOnlyDictionary<NodeId, StatusCode> ReadStatuses { get; init; } // new, empty by default
}
```

## Behaviour

1. **Read statuses and resolution errors.**
`DefaultServerRedundancyHandler` records the status of every Node it
reads from the Server in use (`RedundancySupport`, `ServiceLevel`,
`RedundantServerArray`, `ServerUriArray`, `EstimatedReturnTime`, and
`CurrentServerId` for Transparent redundancy) in
`ServerRedundancyInfo.ReadStatuses`. A peer whose endpoint resolution
fails carries the exact status in `RedundantServer.ResolutionError` (the
discovery `ServiceResult`, `BadTimeout` when the discovery bound
expires, `BadNotFound` when no endpoint matches the security and
transport in use). `IManagedSession.RedundancyInfo` exposes the last
snapshot.
2. **Member probing** (`ProbeMemberServiceLevel`). After a member's
endpoint is resolved, `ManagedSession` reads `Server.ServiceLevel` and
`Server.ServerStatus.State` of every other member over a short session
with the identity of the session in use, bounded by
`PeerDiscoveryTimeout`, and replaces the reported values. A member that
cannot be probed keeps the values reported for it and carries the
failure in `ResolutionError` (for example `BadNotReadable`). With
probing, the initial connect switches to the member with the highest
`ServiceLevel` when it is higher than that of the Server it connected to
(6.6.2.4.5).
3. **`FailoverAsync`.** Fails over a connected session to `target`, or
to the target the redundancy handler selects from a refreshed snapshot,
through the existing in-place recreate (subscriptions are transferred or
recreated; with token reuse enabled a HotAndMirrored target reactivates
the session). The connection state machine gets an internal
`RequestFailover` that enters `Failover` with the requested operation;
`ConnectionStateChanged` reports `Failover` then `Connected`. Throws
`BadNotSupported` without a redundancy handler, `BadInvalidState` when
not connected, `BadNothingToDo` when the handler selects no target, and
the failover error otherwise. A failed requested failover transitions to
`Reconnecting` (not `Disconnected` plus the recovery timer), so the
reconnect policy restores the session at once. Cancelling `ct` stops
waiting; a failover in progress completes.
4. **ServiceLevel monitoring** (`ServiceLevelMonitoringInterval`). While
connected to a non-transparent set, `ManagedSession` reads the
`ServiceLevel` of the Server in use at the interval. While it is Healthy
nothing else happens (a Healthy Server is not left, 6.6.2.4.3).
Otherwise the snapshot is refreshed (with probing when enabled),
`ShouldFailover` decides, and a warranted failover runs `FailoverAsync`
to the handler's target. The loop ends with the session.

The switch logic of the automatic failover (`HandleFailoverAsync`) is
factored into a helper shared with the initial selection and
`FailoverAsync`; its behaviour is unchanged.

## Tests

- `ManagedSessionServerRedundancyTests` (new, in-process Warm set of two
`ReferenceServer` fixtures with distinct ApplicationUris,
`Server.ServerRedundancy` promoted to `NonTransparentRedundancyType`
with each other in `ServerUriArray`, `ServiceLevel` set through
`IServerServiceLevelControl`, endpoints resolved by a test
`IRedundantServerEndpointResolver`):
- without probing the session stays on the configured Server; the peer
has `ServiceLevelKnown = false` (defaults unchanged);
- with probing the initial connect selects the member with the higher
`ServiceLevel` (250 over 210);
- a member that refuses the `ServiceLevel` read keeps an unknown
`ServiceLevel` and reports `BadNotReadable` in `ResolutionError`;
- a refused `RedundantServerArray` reports `BadNotReadable` in
`ReadStatuses`;
- lowering the `ServiceLevel` of the Server in use to 150 while the peer
reports 230 fails over within the monitoring interval,
`ConnectionStateChanged` reports only `Failover` and `Connected`, the
same subscription object keeps delivering data changes from the new
Server (sequence numbers restart there, as the subscription is recreated
on another Server);
- `FailoverAsync` to an explicit target and to the handler-selected
target;
- `FailoverAsync` without a redundancy handler throws `BadNotSupported`.
- `ServerRedundancyHandlerTests`: resolution error of a refusing and of
an unresolved peer, read status of each Node, validation of the
monitoring interval.
- `ConnectionStateMachineTests`: a requested failover runs its operation
in `Failover` and bypasses the failover delegate; a failed requested
failover reconnects without `Disconnected`; `RequestFailover` is refused
when not connected.

## Compatibility

- Source/binary additions only, except that `IManagedSession` gains
`RedundancyInfo`; `ManagedSession` is the only implementation in the
repository, external implementers of `IManagedSession` must add the
property.
- `RedundantServer.ServiceLevelKnown` changes from internal to public
(same default `true`).
- With the default options nothing changes at runtime: no probing, no
monitoring, the automatic failover path is unchanged. The only
default-visible differences are the new `ReadStatuses`/`ResolutionError`
data.
- Probing opens one short additional session per member;
reverse-connect-only members cannot be probed and report the probe
failure in `ResolutionError`.

## Related Issues

- No tracking issue; the problem and the design are described above.

## 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.
- [ ] 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. (Run
locally: the affected test classes on net48 and net10.0 and the whole
Opc.Ua.Client.Tests project on net10.0; not the whole UA.slnx.)
- [ ] 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: Marc Schier <marcschier@users.noreply.github.com>
2026-10-23 05:58:57 +02:00
Wolfgang ColsmanandMarc Schier be9ccf912c Client: evaluate the durable subscription lifetime on every physical create (#4882)
# Description

## Problem

`ISubscription.SetAsDurableAsync(TimeSpan lifetime, CancellationToken
ct)` marks a subscription durable with the Server method
`SetSubscriptionDurable` (OPC 10000-5, "SetSubscriptionDurable Method";
OPC 10000-4, "Durable Subscriptions"). `SetSubscriptionDurable` must be
called before the subscription has monitored items; a conforming server
rejects it afterwards with `Bad_InvalidState`.

`LogicalSubscription` stores the `TimeSpan` and installs an
`OnAfterCreateAsync` hook on each partition. The partition state machine
runs the hook after every `CreateSubscription` and before the first
`CreateMonitoredItems`, so durability survives every later physical
create:

- recreate after a service call reports `BadSubscriptionIdInvalid`
(`Subscription.ApplyStateChangesAsync` → `ResetToRecreateAsync`),
- recreate after `TransferSubscriptions` fails on session recreate
(`SubscriptionManager.RecreateSubscriptionsAsync` → `RecreateAsync`) or
on load (`RestoreTransferAsync` → `ResetToRecreateAsync`),
- recreate after a status-change timeout or an unsolicited
`Good_SubscriptionTransferred`,
- a new secondary partition (`TryApplyDurableToNewPartition`).

The hook always re-applies the lifetime fixed by the first call. An
application whose durable lifetime must not exceed a bound that shrinks
over time (for example an authorization or retention window that ends at
a known instant, after which the server must no longer queue
notifications) cannot keep that bound after a recreate, and it has no
way to supply a new value at the right moment:

- calling `SetAsDurableAsync` again after the recreate is rejected once
monitored items exist;
- `ISubscriptionNotificationHandler.OnSubscriptionStateChangedAsync` is
dispatched fire-and-forget, so a call made from it can arrive after the
first `CreateMonitoredItems`;
- `SubscriptionOptions` has no durable setting and `OnAfterCreateAsync`
is internal.

## API

```csharp
namespace Opc.Ua.Client.Subscriptions
{
    public interface ISubscription
    {
        // existing
        ValueTask<TimeSpan> SetAsDurableAsync(TimeSpan lifetime, CancellationToken ct = default);

        // new
        ValueTask<TimeSpan> SetAsDurableAsync(Func<TimeSpan?> lifetime, CancellationToken ct = default);
    }
}
```

## Behaviour

- The provider is evaluated once for the call itself; the value is sent
to every partition that is already created (unchanged: the call returns
the minimum revised lifetime of those partitions, or the requested value
when none is created yet).
- The provider is stored and evaluated again by the existing
after-create hook before every later physical create, between
`CreateSubscription` and the first `CreateMonitoredItems` of that
create.
- A `null` result requests no durability for that create: no
`SetSubscriptionDurable` is sent and the recreated subscription is a
regular one. A `null` result for the call itself returns `TimeSpan.Zero`
without contacting the server.
- An exception thrown by the provider during a later create is handled
exactly like a rejected `SetSubscriptionDurable` call today: the state
machine logs a warning, reports `SubscriptionState.Modified`, and the
subscription continues (its monitored items are created). An exception
during the call itself propagates to the caller.
- `SetAsDurableAsync(TimeSpan)` now forwards to `SetAsDurableAsync(() =>
lifetime)`; its behaviour is unchanged.
- `docs/Subscriptions.md` describes the provider and corrects the
statement that the hook is cleared after invocation (on master it stays
installed).

Not included: a notification reporting the outcome (revised lifetime or
status) of each re-applied `SetSubscriptionDurable`. Today a failed
re-apply is only logged and surfaced as `SubscriptionState.Modified`;
this could be a follow-up if maintainers want it.

## Tests

New tests in
`tests/Opc.Ua.Client.Tests/Subscription/SubscriptionTests.DurableLifetime.cs`
drive a real partition (`Subscription`) behind
`SubscriptionManager`/`LogicalSubscription` against mocked services and
record `CreateSubscription`, `SetSubscriptionDurable` (requested hours),
`CreateMonitoredItems`, `TransferSubscriptions` and
`DeleteSubscriptions` in call order. The provider returns 10 h for the
initial call and 7 h afterwards:

-
`DurableLifetimeProviderIsEvaluatedOnRecreateAfterSubscriptionIdInvalidAsync`:
`ModifySubscription` returns `BadSubscriptionIdInvalid`; the recreate
sends 7 h between `CreateSubscription` and the first
`CreateMonitoredItems`.
-
`DurableLifetimeProviderIsEvaluatedOnRecreateAfterFailedTransferAsync`:
`TransferSubscriptions` on session recreate returns
`BadSubscriptionIdInvalid`; delete, create, 7 h, items.
- `DurableLifetimeProviderIsEvaluatedForNewPartitionAsync`:
`MaxMonitoredItemsPerPartition = 1`; the second item mints a partition
that sends 7 h before its items.
-
`DurableLifetimeProviderReturningNullSendsNoSetSubscriptionDurableAsync`:
provider returns `null` on recreate; no `SetSubscriptionDurable`, items
are created.
-
`DurableLifetimeProviderThatThrowsLeavesRecreatedSubscriptionRunningAsync`:
provider throws on recreate; no `SetSubscriptionDurable`, items are
created, subscription stays created.
- `DurableFixedLifetimeIsReappliedOnRecreateAsync`: regression of the
`TimeSpan` overload (10 h again on recreate).

Unit tests in `LogicalSubscriptionTests`:
`SetAsDurableWithProviderEvaluatesItOnceForAllCreatedPartitionsAsync`,
`SetAsDurableWithProviderReturningNullSendsNothingAsync`,
`SetAsDurableWithNullProviderThrowsAsync`.

Negative control: with the hook evaluating the provider once at
installation (the previous fixed-value behaviour), the
recreate-after-`BadSubscriptionIdInvalid`, failed-transfer, null and
throwing-provider tests fail (4 of 10).

Local results (Windows, .NET SDK 10.0.402, Release):

| Run | Result |
| --- | --- |
| `Opc.Ua.Client` build, net48/net8.0/net9.0/net10.0 | 0 warnings, 0
errors |
| `SubscriptionTests` + `LogicalSubscriptionTests`, net10.0 | 105/105
passed |
| `SubscriptionTests` + `LogicalSubscriptionTests`, net48 | 105/105
passed |
| `Opc.Ua.Client.Tests`, namespace `Opc.Ua.Client.Subscriptions`,
net10.0 | 353/353 passed |
| `Opc.Ua.Subscriptions.Tests` `SubscriptionManagerCoverageTests` +
`StreamingSubscriptionCoverageTests`, net10.0 | 72/72 passed |
| `Opc.Ua.Subscriptions.Durable.Tests` `SubscriptionDurableTests` +
`DurableSubscriptionTest*` (in-process reference server with durable
subscriptions enabled), net10.0 | 21/21 passed |

## Related observation (not changed here)

A secondary partition minted by `SubscriptionManager.Add`'s partition
factory starts its state machine in the `Subscription` constructor,
before the factory calls `TryApplyDurableToNewPartition`. When
`CreateSubscription` completes before the factory returns (observed with
a synchronously completing mock), `RunAfterCreateHookAsync` finds no
hook and the new partition is created without `SetSubscriptionDurable`,
for the `TimeSpan` overload as well. With a real round trip the hook is
installed first;
`DurableLifetimeProviderIsEvaluatedForNewPartitionAsync` therefore holds
the secondary's `CreateSubscription` response until the factory has
returned. Installing the hook before the partition's first state-manager
pass would remove the window.

## Compatibility

- Source and binary compatible for callers of `ISubscription`.
- `ISubscription` gains a member. Custom implementations of the public
interface (outside the stack; the stack's own implementations are
internal) must add the overload; a default interface implementation is
not possible because the library also targets .NET Framework 4.8. The
three test doubles in the repository were updated.
- No wire, configuration or server change.

## Related Issues

- Independent of #4881; with #4881 the lifetime provider is also applied
to a new partition that is created before the partition factory returns.

## 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. (Run
locally: the affected test classes on net48 and net10.0 and the whole
Opc.Ua.Client.Tests project on net10.0; not the whole UA.slnx.)
- [ ] 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: Marc Schier <marcschier@users.noreply.github.com>
2026-10-22 18:51:01 +02:00
Wolfgang Colsman 00479792ae Client: install the durable hook before a new partition creates itself (#4881)
# Description

## Defect

`LogicalSubscription.SetAsDurableAsync(TimeSpan)` records the durable
lifetime and installs an
`OnAfterCreateAsync` hook on every partition, so that
`SetSubscriptionDurable` (OPC 10000-5) is
called between `CreateSubscription` and the first `CreateMonitoredItems`
of each physical create,
as OPC 10000-4 requires for durable subscriptions.

A secondary partition minted on demand (when the logical subscription
exceeds
`MaxMonitoredItemsPerPartition`) starts its state machine inside its
constructor. The partition
factory in `SubscriptionManager` installed the hook only after the
constructor returned
(`TryApplyDurableToNewPartition`). When the server answered
`CreateSubscription` before the factory
returned, the create pass read no hook, the partition was created
without `SetSubscriptionDurable`,
and its monitored items were created on a non-durable subscription. The
hook only runs after a
create, so it never ran for that partition; a later
`SetSubscriptionDurable` is refused by a
conforming server once items exist (`Bad_InvalidState`). The outcome
depended on timing.

## Reproduction


`SubscriptionTests.DurableIsSetOnNewPartitionCreatedBeforeFactoryReturnsAsync`:
a mocked server
answers every call immediately, `MaxMonitoredItemsPerPartition = 1`, the
logical subscription is
made durable for 10 h, and the partition factory returns only after the
new partition reports
`Created`. Result on `master` (bc97bf720):

```
Expected: < "create:22", "durable:22:10h", "items:22", "create:23", "durable:23:10h", "items:23" >
But was:  < "create:22", "durable:22:10h", "items:22", "create:23", "items:23" >
```

## Fix

The partition is now wired before its state machine starts:

- `Subscription` (internal) constructor takes an optional
`Action<IManagedSubscription> initialize`
  and invokes it immediately before starting the state machine.
- `ISubscriptionManagerContext.CreateSubscription` (internal) takes the
same optional callback and
must run it before the first create pass; `DefaultSubscriptionEngine`
forwards it to the
  constructor.
- `SubscriptionManager` passes an initializer for on-demand secondary
partitions (both the
`Add` and the transfer-restore paths) that attaches the reactive
fallback and the durable hook,
  which it previously did after the factory returned.

The hook itself, `LogicalSubscription`, the primary and
preloaded-partition paths are unchanged.

## Tests

- New: `DurableIsSetOnNewPartitionCreatedBeforeFactoryReturnsAsync`
(fails on `master`, passes) and
`InitializeRunsBeforeTheFirstCreatePassAsync` (initializer runs while
the subscription is not yet
created and a hook installed there runs after the first
`CreateSubscription`).
- Test fakes implement the new parameter;
`FakeSubscriptionManagerContext` gains
`CreateInitializedSubscriptionFactory` and otherwise runs the
initializer after its factory, as
  before.
- Regression: `SubscriptionTests`, `LogicalSubscriptionTests` (net10.0
and net48), the
  `Opc.Ua.Client.Subscriptions` namespace of `Opc.Ua.Client.Tests`,

`SubscriptionManagerCoverageTests`/`StreamingSubscriptionCoverageTests`,
and the durable
integration tests (`SubscriptionDurableTests|DurableSubscriptionTest`).

Results (Release, commit 4250c2556): focused classes net10.0 98/98 and
net48 98/98,
`Opc.Ua.Client.Subscriptions` namespace net10.0 346/346, coverage tests
72/72, durable
integration 21/21; the two new tests passed 20 of 20 repeated runs.
`Opc.Ua.Client` builds for
net10.0, net9.0, net8.0 and net48 with 0 warnings and 0 errors; `dotnet
format` whitespace and
style report no changes for the changed files.

## Interaction with the durable lifetime provider branch

`git merge-tree --write-tree` against
`feature/durable-lifetime-provider` reports no conflicts. That
branch changes `InstallDurableHook` and `TryApplyDurableToNewPartition`
but not their call site; with
this fix its provider-based hook is also installed before a new
partition's first create.

## Compatibility

No public API change. `Subscription` and `ISubscriptionManagerContext`
are internal; the new
parameters are optional and last. Behaviour is unchanged except that the
reactive fallback and the
durable hook of a new partition are in place before its first create
pass.

## Related Issues

- No tracking issue; the problem and the design are described above.

## 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.
- [ ] 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. (Run
locally: the affected test classes on net48 and net10.0 and the whole
Opc.Ua.Client.Tests project on net10.0; not the whole UA.slnx.)
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [ ] I have addressed **all** PR feedback received.
2026-10-22 12:59:43 +00:00
47fbf1673b PlasticsRubber datasets: keep transfer reclamation alive when a tick's gate wait times out (#4879)
## Proposed changes

Fixes a timing-dependent failure in `ProductionDatasetBinding` (added by
#4834). It showed up on CI for #4874, which does not touch this code:
macOS net10.0, run 39725790220, job 119649381350,
`ProductionDatasetProtocolTests.ServerDeadlineCancelsProviderAndExplicitClientCancellationDoesNotAllocateAsync`:

```
System.AggregateException : Dataset binding cleanup failed. (The operation was canceled.)
   at ProductionDatasetBinding.DisposeCoreAsync() ... line 571
   at ProductionDatasetServerFixture.DisposeAsync() ...
```

### Race

`MaintainTransfersAsync` admits each 1 s reclamation tick through
`RunRegisteredAsync`. Its gate wait is linked to `OperationTimeout`. If
another operation holds the gate past that deadline, the tick's
`m_gate.WaitAsync` throws `OperationCanceledException` while
`m_stopping` is **not** cancelled. The catch filter `!reclaiming &&
m_stopping.IsCancellationRequested` therefore doesn't match, so the
maintenance task ends Canceled:

- periodic reclamation of abandoned transfers stops silently for the
rest of the binding's life (`m_maintenance.IsFaulted` stays false), and
- `DisposeCoreAsync` awaits `m_maintenance` and reports the stale OCE as
a cleanup failure.

The failing test sets `OperationTimeout = 150 ms` and blocks the commit
provider until that deadline, so the bug shows only when a tick lands
inside that window. It is rare locally and more likely on a slow runner.

The cancellation does **not** come from disposal interrupting a started
reclaim: `ExpireCoreAsync` and `RetireAsync` observe no token. The stack
trace of the repro confirms that the OCE comes from the gate wait.

### Fix

An admission wait that expires before the reclaim starts (`!reclaiming`)
now skips that tick and retries at the next interval. When `m_stopping`
is cancelled, the loop still ends. A reclaim that has already started
still reports its failures, and disposal still retires every remaining
slot.

### Regression test


`ReclamationTickTimingOutBehindBusyGateKeepsReclaimingAndDisposesCleanlyAsync`:
`OperationTimeout = 100 ms`, and a commit provider holds the gate for
2.5 s (across at least two ticks). The test then opens an abandoned
transfer, advances the test clock past `TransferTimeout`, and waits for
**periodic** reclamation to remove the file node. Finally it disposes
the binding.

- Before the fix: both assertions fail. The file node is never
reclaimed, and disposal throws the same `AggregateException` as CI, with
the inner OCE from `SemaphoreSlim.WaitAsync` in
`MaintainTransfersAsync`.
- After the fix: passes. The full `Opc.Ua.PlasticsRubber.Datasets.Tests`
project passes on net10.0 (52/52). net48 builds with 0 warnings.

## Types of changes

- [x] Bugfix (non-breaking change which fixes an issue)

🤖 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-22 12:46:01 +00:00
Alexander Köpke fb4eece24d Fix OpenUSD sample discovery and migrate artifacts to xRegistry (#4880)
# 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._

Fix OpenUSD sample discovery and integrate artifact delivery with the
xRegistry companion model.

- Load xRegistry before OpenUSD in the sample node managers, mount
stages with `Organizes`, and align root-layer identifiers with served
`.usda` files.
- Bootstrap immutable logical Resources and exact Versions in
`Artifacts`. Stage `Assets` folders provide non-owning `Organizes`
views, and explicit container identifiers allow sharing.
- Update the binding model to draft 0.9.0 while preserving existing
NodeIds and the `HasAddIn` correction.
- Disambiguate default container identifiers by stage instance, publish
the required read-only capabilities map, and preserve standard and
xRegistry BrowseName namespaces.
- Add registry, file-delivery, sharing, collision, qualified-browse, and
live-discovery regressions. Update the OpenUSD documentation.

Review fixes are in 6ac47a5b82. All four
review threads have been answered and resolved.

**Local validation**

| Command | Result |
| --- | --- |
| `dotnet test tests/Opc.Ua.OpenUsd.Tests/Opc.Ua.OpenUsd.Tests.csproj
--no-restore -f net10.0 -p:CustomTestTarget=net10.0 --verbosity quiet` |
Passed: 1,178 tests. |
| `dotnet test tests/Opc.Ua.OpenUsd.Tests/Opc.Ua.OpenUsd.Tests.csproj -f
net48 -p:CustomTestTarget=net48 --filter
FullyQualifiedName~UsdAssetDeliveryTests --verbosity quiet` | Passed: 36
focused regressions, including build and dependency restore. |
| `dotnet test tests/Opc.Ua.OpenUsd.Tests/Opc.Ua.OpenUsd.Tests.csproj
--no-build --no-restore -f net48 -p:CustomTestTarget=net48 --verbosity
quiet` | Failed: 937 passed; five generator integration tests failed
during fixture setup because Mono does not implement
`ECDsa.Create(ECCurve)`. |

The full `UA.slnx` suite and coverage measurement were not run locally.

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

No linked tracking issue.

## 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-22 12:45:21 +00:00
romanettandClaude Opus 5.5 bc97bf7206 Interop: foreign peers log on with the trusted X509 user certificate (#4878)
# Description

The nightly [Foreign stack
interop](https://github.com/OPCFoundation/UA-.NETStandard/actions/runs/39717131386)
run failed on all 13 jobs with one check: `X509UserToken` →
`BadIdentityTokenRejected`.

#4834 made the reference server stop auto-accepting X509 user
certificates (application autoaccept is not user enrollment). It also
changed `LegacyClientInteropTests` to trust a generated user certificate
and pass it to the peer as `--x509-user-cert` (PFX). Only the 1.5 peer
reads that option. The foreign peers (gopcua, milo, asyncua, node-opcua,
open62541) still created their own self-signed certificate, which the
server now rejects as untrusted. The foreign workflow is skipped on PRs
without the `interop` label, so #4834's CI did not catch it.

## Changes

- `LegacyClientInteropTests` also writes the trusted user certificate as
DER plus an unencrypted PKCS#8 PEM key and passes `--x509-user-cert-der`
/ `--x509-user-key-pem` to the feature-check run.
- gopcua, milo, asyncua, node-opcua and open62541 use those files in
`X509UserToken`. Without the options they still create a self-signed
certificate.
- The peers README documents the options.

No server behaviour changes. async-opcua is unchanged: its
`X509UserToken` check is already an expected difference (empty user
token signature) and its job passed.

## Verification

Run locally on Windows, net10.0 Release:
- gopcua: whole `LegacyClientInteropTests` class, 81/81.
- milo, asyncua, node-opcua and open62541: the 18 feature checks each
pass, including `X509UserToken`.
- The default 1.5 peer still passes all 18 feature checks.
- The fixture builds for net48.

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 09:10:14 +02:00
romanettandClaude Opus 5.5 67c827c354 Wait for the server to be running before GranulateDrying tests connect (#4876)
## Problem

In CI run
[39616233672](https://github.com/OPCFoundation/UA-.NETStandard/actions/runs/39616233672/job/119282247661)
(test-windows-net48 7/18, on docs-only PR #4868),
`GranulateDryingClientServerTests(False)` failed all 94 tests in
`OneTimeSetUp`. `GetEndpointsAsync` returned `BadServerHalted`.

## Root cause

`GranulateDryingTestHost` signalled readiness from the
`ConfigureGranulateDrying` callback. That callback runs during
`DiNodeManager.CreateAddressSpaceAsync`, inside
`StandardServer.StartApplicationAsync`, so the server has not finished
starting yet. But the TCP listener is already accepting at that point:
`TcpServiceHost` opens it in `ServerBase.InitializeServiceHostsAsync`,
before `StartApplicationAsync`. Until `StandardServer` clears
`ServerError` (its constructor sets `BadServerHalted`),
`EndpointBase.GetServerForContext` answers every request with
`BadServerHalted`.

In the CI run, this was the first server started in the test process on
a cold net48 runner, and it took about 16 s to reach the callback. The
client then connected about 0.4 s later, while the rest of startup
(session and subscription managers, node-manager readiness) was still
running. The TRX log shows the secure channel opening at 14:41:59.43 and
`GetEndpoints` failing about 0.1 s later.

**Repro:** adding `await Task.Delay(3000)` after `ready.TrySetResult` in
the callback fails all 188 `GranulateDryingClientServerTests` locally
(net48 Release) with the same `BadServerHalted` stack. With this fix and
the same delay, all 188 pass.

## Fix

The host now registers an `IServerStartupTask` and waits for it.
`OpcUaServerHostedService` runs startup tasks only after
`application.StartAsync` has returned, which is when the server is
Running and `ServerError` is cleared. The host still waits on
`ExecuteTask` as well, so a failed start throws instead of hanging. This
is the same pattern `RepositoryCompanionLiveTests` already uses.

## Tests

- net10.0: `Opc.Ua.PlasticsRubber.Runtime.Tests` 597/597 passed.
- net48 Release: `GranulateDryingClientServerTests` 188/188 passed, both
with and without the injected delay. On a full project run, 592/597
passed. The 5 failures are `DiagnosticsBindingTests` and
`MaterialListBindingTests`, which don't use this host. They fail only
locally, because the PFX path in the deep worktree is longer than the
net48 MAX_PATH limit (`DirectoryNotFoundException`). They passed in the
CI run.

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 09:09:22 +02:00
romanettandClaude Opus 5.5 4fd76c1ec6 Fix PublishWorkerCount race, quiet harness stop errors, track certificate leaks in CI (#4875)
Three reliability fixes found during the client GC investigation.

## 1. PublishWorkerCount lagged a running publish worker (flaky test)


`SubscriptionTests.IntentionalDeletionDropsPublishDemandWithoutUnregisteringSubscriptionAsync(True)`
failed once on Linux CI (run 39261903989): `PublishWorkerCount` was 0
although a publish was already in flight.

**Cause.** The publish controller started new workers with `Task.Run`
and only then updated `PublishWorkerCount`. A worker could issue its
first publish before the controller got that far.

**Proof.** A 200 ms delay between starting the workers and updating the
count reproduces the CI assertion in both test cases.

**Fix.** The controller now sets the count before it starts the workers.

**Verification:**
- The test passes with the delay still in place.
- 30 of 30 loop runs passed.
- Client.Tests (net10.0): 3282 passed. One unrelated test failed under
machine load:
`SessionBindingLiveTests.BoundRequestCannotDispatchWhileNativeReplacementIsPendingAsync`,
a channel reconnect. It passes 5/5 on its own.
- The net48 build of Opc.Ua.Client is clean.

## 2. ServerLoadHarness: "error: BadRequestInterrupted" at the end of a
run

The harness printed `error: [80840000] (BadRequestInterrupted)` lines
that looked like warm-up failures. They are the requests still in flight
when the workload is stopped. `ChannelAsyncOperation` reports a
cancelled token as BadRequestInterrupted, not as
`OperationCanceledException`. Every one of these errors had the stop
token cancelled (instrumented run with read, browse and write
workloads).

This is not a product bug. The read/write loops now exit quietly in that
case, and read, write and sub runs no longer print the errors.

## 3. CI: record certificate allocation stacks

Opc.Ua.Sessions.Tests sometimes fails its assembly teardown with
"Certificate leak detected: 1 instance(s)":
- 4 jobs between 2026-10-12 and 2026-10-19, out of about 278 macOS
net10.0 shard runs.
- Never seen on Linux or Windows.
- The runs already included the fix for the failed HTTPS listener open
(#4775).

It did not reproduce locally: one full tracked run and six tracked runs
of the WSS/HTTPS/reverse-connect fixtures were all clean. The
per-fixture attribution showed no unbalanced fixture, which suggests
work that outlives its fixture.

The CI test step now sets `OPCUA_CERTIFICATE_LEAK_TRACKING=1`, so the
next occurrence reports the allocation stack and the owning fixture.
Tracked handles are removed from the tracker on dispose, and the
WSS/HTTPS fixtures ran no slower with tracking on.

The leak itself is **not** fixed by this PR.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 18:09:59 +00:00
romanettandClaude Opus 5.5 df6adec1af Refresh server benchmarks for 2.0.2: GC modes, processor placement, delivery (#4868)
Closes #4842.

Measures the items that `docs/Benchmarks.md` listed under *Open
measurements* and makes every published table reproducible from
checked-in files.

All runs: `master` f6b27025a, AMD Ryzen AI 9 HX 370 (4 Zen 5 + 8 Zen 5c
cores, 24 logical), 64 GB, Windows 11 26300, Balanced power plan, .NET
SDK 10.0.402 / runtime 10.0.13. Each table has 5 rounds per variant,
alternated and reversed every other round, and reports the median with
its min–max range.

## Docs
- **Reference Read table** re-run with ranges. The 2026-10-18 single
runs move to a *Historical results* subsection.
- .NET pinned to 4 logical processors: 440k req/s at 8.9 µs CPU per
Read.
  - open62541: 137k req/s at 7.3 µs.
  - .NET on 24 logical processors: 600k req/s at 22.9 µs.
- **Garbage collector modes**: workstation, Server GC with DATAS, and
Server GC with fixed heaps, on `native-read`, `read` and `events`.
- Server GC gave 45–89 % more throughput than workstation GC.
Workstation GC paused the server for 30–46 % of the window.
- Turning DATAS off cut GC pauses 4.6–8× at 1.9–2.8× the working set,
with no throughput difference beyond the ranges.
- **Processor placement** on the hybrid CPU, with and without
SignAndEncrypt.
- Server on the Zen 5 logical processors (`0xFF`): median throughput
within 7 % of the default placement at about half the CPU per Read (11.7
vs 22.9 µs).
- **Publish and event delivery** on the merged 2.0.2 master: delivered
share, latency, CPU and allocation per notification, and GC pause.
- **Recommended baseline** now cites these measurements.
`ServerScalability.md` GC and affinity paragraphs are updated to match.
- **Open measurements** now lists only what is still unmeasured: Linux,
Intel hybrid CPUs, a dedicated host, and session establishment and
scaling.

## Regression found (documented, not fixed here)
The 50 sessions × 15 subscriptions × 600 items publish shape from #4829
now delivers only about 26 % of the expected notifications.

| Commit | Delivered | Server CPU / notification |
|---|--:|--:|
| `37a2a831f` (#4829 merge) | 93 % | 3.9 µs |
| `f6b27025a` (master) | 23 % | 46 µs |

Both rows use the same harness arguments. A `git bisect` finds 65a67dd2f
(#4757) as the first bad commit: its parent 0edc3b55a delivers 98 % at
2.8 µs, while 65a67dd2f delivers 6 % at 94 µs. The fix is being worked
on separately.

## Tooling
- `perf/variants.sh` runs one harness build under several
configurations, given as `name|env|args` lines. It discards and repeats
any run that overlaps another test host or load harness.
- `perf/variants-summary.ps1` prints the median and range of each metric
per variant.
- `perf/benchmarks/*.txt` holds the variant and scenario files for each
table.
- `perf/README.md` documents all of the above.

## Test plan
- [x] All four batches ran with the checked-in files. 2 of 140 runs were
discarded because of foreign load; none failed.
- [x] The summaries reproduce the tabulated numbers.
- [x] CRLF line endings preserved in the edited docs.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 16:20:04 +00:00
romanettandClaude Opus 5.5 48871a7e87 Client: completed requests no longer keep their responses alive (#4870)
## Problem
A client request that lives across a GC is promoted. Publish requests
always are: they wait at the server for the publishing interval. When
the response arrives, two **dead** old objects still reference it:

1. `UaSCUaBinaryChannel.WriteOperation.MessageBody`:
`UaSCUaBinaryClientChannel.SendRequestAsync` read it but never cleared
it.
2. The box of the generated `SessionClient.*Async` method. It is `async
ValueTask<T>` with the default builder, so a call that completes
asynchronously leaves a `Task<T>` box holding the result and the hoisted
`response` locals.

The GC treats old objects as live. Every gen0 and gen1 collection
therefore keeps the whole response alive through the card table until a
full collection. With 100k monitored items, every `PublishResponse` (100
`MonitoredItemNotification`s) ended up promoted to gen2.

Evidence (ClrMD scan of a live client dump, 25 sessions × 40
subscriptions × 100 items): 1,073 KB of gen0 was kept only by dead old
objects, against 136 KB kept by live ones. The dead holders were exactly
the two above, holding the same responses. Gen2 held 37 MB of dead
notifications.

## Change
- `SendRequestAsync` clears `operation.MessageBody` once it has read the
response.
- `ClientApiTemplates`: generated `SessionClient` async methods use
`PoolingAsyncValueTaskMethodBuilder` on .NET 6+. A completed box resets
its result and state machine. This is the same pattern #4849 applied to
the channel and `ManagedSession`.

Contract note: the generated `SessionClient` calls now return pooled
value tasks, so each must be awaited once, like any value task. All
sync-over-async call sites in the repo use `.AsTask()` first.

After the fix, young data kept only by dead old objects is 0 KB (was
1,073 KB).

## Measurements
`perf/ServerLoadHarness`, client `client-gc:` line. Master vs this
branch, 3 alternating rounds, runs gated on foreign CPU load (shared
machine). Ranges are over the rounds.

| scenario | build | pause share | GC p50 ms | survived/gen0 |
survived/gen1 | gen2 MB | B/op |
|---|---|---|---|---|---|---|---|
| sub 100k items (25×40×100, pub 500 ms) | master | 4.9–5.4 % | 7.9–8.2
| 2.28–2.57 MB | 1.4–1.7 MB | 121–123 | 235–238 |
| | this PR | 4.0–4.5 % | 6.1–6.9 | 0.64–0.71 MB | 0.19 MB | 86 |
233–240 |
| sub 50k items (25×20×100) | master | 1.7–2.2 % | 5.5–6.5 | 2.1–2.2 MB
| 0.8–1.1 MB | 56–62 | 233–238 |
| | this PR | 1.7–1.8 % | 5.2–5.5 | 0.39–0.41 MB | 0.1–0.2 MB | 44–49 |
232 |
| read 1 node, 200 sessions | master | 8.9–9.2 % | 2.2–2.4 | 200 KB | |
20 | 1456 |
| | this PR | 7.3–7.7 % | 2.2–2.7 | 113–163 KB | | 20 | 1272 |
| read 1 node, 20 sessions | master | 3.3–3.6 % | 0.9–1.2 | 50–53 KB | |
4.4 | 1456 |
| | this PR | 2.8–2.9 % | 0.85–1.16 | 32–46 KB | | 4.4 | 1272 |

The first fix round at 100k ran while another process starved the server
(46k notif/s); it is left out of the fix row.

- At 50k items, gen1 collections drop from 15 to 1 per 10 s.
- GC phases at 100k items (TraceAnalyzer `gc`): card marking 3.3 → 2.55
ms (2.8 → 0.72 MB marked), plan+relocate+compact 6.05 → 3.27 ms.
- Allocation: −184 B per request (−12.6 %), because the `Task` box per
call is gone.

## Tests
net10.0: Opc.Ua.Client.Tests 3283 passed / 0 failed, Opc.Ua.Core.Tests
6535/0, Opc.Ua.SourceGeneration.Core.Tests 4412/0, Opc.Ua.Sessions.Tests
857/0. The net48 Release build of Opc.Ua.Client (with Core) is clean.

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 18:14:42 +02:00
6fe5399510 Add UaLens desktop engineering workspace (#4822)
# Description

Add **UaLens**, an Avalonia desktop engineering workspace for OPC UA
exploration, monitoring, typed editing, administration and guided
companion-specification tasks.

This is a **new draft PR for `pisrapp` against `master`**, replacing
closed, unmerged #4462 without reopening it. The shared prerequisites
(#4801, #4802, #4803, #4804 and #4806) have all merged; their
implementation is no longer part of this PR's delta.

### Scope

- Multi-document workspace with connection, security and identity
controls; browsing, subscriptions, events, alarms, history, writes and
method calls.
- Offline NodeSet2 exploration, typed scalar/array/matrix editing,
certificate/GDS and account/role tools.
- UaLens companion workflows, repository sample launching and the Aspire
AppHost.
- PubSub commissioning and capture/replay integration, plus the
UaLens-facing OpenUSD viewport and rendered Vision workflows.
- UaLens projects, test assemblies, UI assets, guides,
solution/dependency entries, desktop test wiring and delivery workflows.

Head **`6f46c51fcfef275059bbe77ef39c64c19ebc1053`** is one UaLens-only
commit directly above master
**`44a8cb277df450162c29313e2f4c862e7c683752`**. The rebase required no
conflicts. All **967 UaLens file blobs**, the full-index binary patch,
commit-message text and author/co-author attribution are unchanged from
the previously published layer. There is **no implementation delta under
`src/`**. The original history is retained in recovery refs, including
`refs/pr-stack/backups/master20261017/ualens`.

No feature was disabled, and no export/path safeguards, native
acceptance criteria, test assertions or CI gates were weakened. The four
previously recorded blank-line-at-EOF findings in the UaLens dialog
files remain unchanged; they are not reported as newly fixed.

### Validation

Fresh validation ran on the **exact PR head `6f46c51f`**, using the
existing pinned SDK **10.0.401**, normal repository analyzers and
physical/non-linked build outputs:

```powershell
$d = 'D:\git\UA-.NETStandard_master\TestResults\ualens-528-deep-repro\sdk\dotnet.exe'
$b = 'D:\git\UA-.NETStandard_master\TestResults\ua-stack-feedback20261016'
$r = 'C:\Users\mschier\.copilot\session-state\abd33a2e-7139-44e1-9912-6e4451102b4e\files\ualens-new-draft20261017-net10'
$env:DOTNET_ROOT = Split-Path -Parent $d
$env:DOTNET_ROOT_X64 = $env:DOTNET_ROOT
$env:DOTNET_CLI_TELEMETRY_OPTOUT = '1'
$env:PATH = $env:DOTNET_ROOT + [IO.Path]::PathSeparator + $env:PATH

& $d test 'tests\Opc.Ua.Lens.Tests\Opc.Ua.Lens.Tests.csproj' --no-restore --artifacts-path $b -c Release -f net10.0 -p:CustomTestTarget=net10.0 --filter 'TestCategory!=LongRunning&TestCategory!=Stress' --logger 'trx;LogFileName=ualens-new-draft.trx' --results-directory $r --blame-hang --blame-hang-timeout 3m --blame-hang-dump-type none --verbosity minimal
# PASS, exit0: Passed: 4524, Failed: 0, Skipped: 0.
```

The saved TRX contains **4524 Passed and 44 explicit/prerequisite
NotExecuted records** (4568 total). The repository verdict helper
confirms zero failed tests, fixture setup/teardown failures or run-level
errors, without toleration. No compiler/analyzer diagnostics were
emitted. Logs, TRX and `ualens-new-draft20261017-validation.json` retain
this exact-head evidence.

**Not run for this new draft:** the full `UA.slnx` net48/net10 matrix,
fresh coverage, other desktop TFMs, application-desktop/native workflow
suites, NativeAOT, GPU/per-RID/installed-runtime or package-release
qualification. UaLens targets .NET8+; no net48 UaLens execution is
claimed. Remote CI and CodeQL are not yet claimed green. Historical
native diagnostics and successful managed tests are not substitutes for
rendered-frame or release acceptance.

The previous PR and retained session artifacts preserve the full
extraction, review, failed-attempt and diagnostic history. Historical
intermittent CI failures were not silently reclassified as root-cause
fixes; no old diagnostic grants or producer/release operations were
reused for this PR.

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

- References #4461, the existing closed UaLens design/qualification
tracking issue; no new design approval is implied.
- Replaces closed, unmerged #4462 for the remaining UaLens work.
- Builds on merged prerequisites #4801, #4802, #4803, #4804 and #4806;
this PR does not close them.

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

The test/coverage item remains unchecked because fresh coverage was not
collected. This draft does not claim CLA confirmation, full-matrix
clearance, native qualification or completed review.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Alexander Köpke <alexander.koepke@microsoft.com>
Co-authored-by: Copilot <Copilot@users.noreply.github.com>
2026-10-21 17:18:19 +02:00
Alexander Köpke 4d00467f7e Add missing component to PumpType instances (#4867)
This pull request introduces improvements to how components and their
references are managed and exposed in both the Machine Vision and
Recycling sample domains. The main focus is on ensuring that components
use the correct `HasComponent` reference type, and that pumps expose
mandatory two-state status labels with appropriate properties and
descriptions. Additional tests are included to verify these behaviors.

**Component Reference Improvements:**

* Updated the `WithComponents` method in the Vision documentation and
implementation to use `HasComponent` references instead of `Organizes`,
ensuring that components are referenced in compliance with OPC UA
modeling best practices.
[[1]](diffhunk://#diff-c885bd41583e603a034705800d1cd03213b4f3c8b1e1611b31121652e6548c5aL340-R341)
[[2]](diffhunk://#diff-89611f4e76aef466943f80ed4079b1275d4ee2016610ad2407f7c8932f2d07e0L252-R252)
* Modified the Machine Vision asset builder and related tests to verify
that components are correctly referenced with `HasComponent`, and that
shared components are properly managed without reparenting or replacing.
[[1]](diffhunk://#diff-c265154f7bd0bbcbaa003aa050270e193e47ba941b41f9158a49f799efbcdf14L506-R517)
[[2]](diffhunk://#diff-c265154f7bd0bbcbaa003aa050270e193e47ba941b41f9158a49f799efbcdf14R528-R535)

**Pump Status Label Enhancements:**

* Refactored pump status variable initialization in the Recycling sample
to ensure that the mandatory two-state properties (`FalseState` and
`TrueState`) are set with the labels "Stopped" and "Running", and that a
descriptive text is provided.
* Updated the Recycling sample documentation to clarify that each pump's
status exposes the mandatory `FalseState` and `TrueState` properties as
"Stopped" and "Running", reflecting the synthetic pump state.

**Testing:**

* Added a comprehensive test to verify that every circulation and drain
pump exposes readable mandatory two-state status labels (`FalseState`
and `TrueState`) with the correct values and reference types.#
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._

## 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 #github-issue-number, ...

## 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.
- [ ] 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-21 13:58:08 +00:00
f6b27025a6 Classify sensitive log parameters with Microsoft.Extensions.Compliance data classification (#4864)
## Proposed changes

Fixes #4857. The stack's log messages wrote network addresses (endpoint,
discovery and reverse connect URLs, host names, IP addresses, ports),
application and user identities, file paths and exception texts as plain
parameters. Applications had no reliable way to keep them out of
general-purpose logs.

This PR replaces the stack's own redaction mechanism with the standard
.NET [data classification and
redaction](https://learn.microsoft.com/dotnet/core/extensions/data-redaction)
packages. Each sensitive `[LoggerMessage]` parameter is classified. The
application decides per classification what reaches its logs: erase it,
replace it with an HMAC token so entries stay correlatable, or pass it
through.

### Taxonomy
`Opc.Ua.Redaction.OpcUaDataClassifications` defines the `OpcUa` taxonomy
and one attribute per classification:

| Classification | Used for | Parameters |
|---|---|---|
| `[NetworkAddress]` | URLs, host and domain names, IP/MAC addresses,
end points, ports | 120 |
| `[ApplicationIdentity]` | certificates, subject, issuer, thumbprint,
ApplicationUri | 103 |
| `[FilePath]` | file, directory and certificate store paths | 67 |
| `[SensitiveText]` | exception or remote-supplied text passed as a
parameter | 55 |
| `[UserIdentity]` | user names, token subjects | 7 |

Correlation ids (`ChannelId`, `SessionId`, ...), status codes and
NodeIds stay unclassified.

### Pipeline and defaults
- Opc.Ua.Core now references
`Microsoft.Extensions.Telemetry(.Abstractions)` 10.11.0, so every
project above Core uses the `Microsoft.Gen.Logging` generator.
Opc.Ua.Client already did, through Http.Resilience. The packages ship
net462 and netstandard2.0 builds, so net48 is supported.
- **A plain `ILoggerFactory` logs `(null)` in place of classified
values.** Classified values are only written by the redaction-aware
logger.
- The new `ILoggingBuilder.EnableOpcUaRedaction()` turns that logger on.
It is `EnableRedaction()` with the discriminator off; otherwise every
unredacted value gets `:ParamName` appended.
`AddOpcUa().AddLogging(...)` and `DefaultTelemetry.Create(...)` call it,
as do the samples, so they log the same information as before until
redactors are registered.
- `services.AddOpcUa()` alone does not change the host's logging.
Generic-host apps call `builder.Logging.EnableOpcUaRedaction()`. This is
documented as a behavior-breaking change in
`docs/migrate/2.0.x/telemetry.md`.

### Obsolete API
`Redact`, `RedactionWrapper<T>`, `IRedactionStrategy`,
`RedactionStrategies` and `SimpleRedactionStrategy` are `[Obsolete]`.
They still work, but the stack no longer uses them.

### Not classified
- The `Exception` passed to a log call. Applications can drop exception
messages with `LoggerEnrichmentOptions.IncludeExceptionMessage`.
- Exception and `ServiceResult` message texts.
- `ServiceResult`/`StatusCode` parameters and logged data values.
- The few log calls whose level is decided at run time.
- `Opc.Ua.Vision.OpenUsd`, which references only Opc.Ua.Types and cannot
see the attributes.

### Generator behavior differences
These are visible to log consumers and to tests:
- `IFormattable` values such as `NodeId` are stored in the structured
state as invariant strings, not as objects.
- The generated log state is a thread-local object that is cleared after
the call.
- Classified values are pooled redactor objects, valid only during the
`Log` call.

Tests that inspected log state after the fact now capture it during the
call. The shared `RecordingLoggerProvider` snapshots classified values.

## Related Issues
- Fixes #4857

## Types of changes
- [ ] Bugfix (non-breaking change which fixes an issue)
- [x] Enhancement (non-breaking change which adds functionality)
- [ ] Test enhancement (non-breaking change to increase test coverage)
- [x] Breaking change (fix or feature that would cause existing
functionality to not work as expected; plain logger factories drop
classified values)
- [x] Documentation Update (if none of the other choices apply)

## Checklist
- [x] I have read the CONTRIBUTING doc.
- [x] I have signed the CLA.
- [x] I ran tests locally with my changes, all passed.
- [x] I fixed all failing tests in the CI pipelines.
- [x] I fixed all introduced issues with CodeQL and LGTM.
- [x] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [x] I have added necessary documentation (if appropriate).
- [ ] Any dependent changes have been merged and published in downstream
modules.

## Further comments
New tests:
- `LogClassificationCoverageTests` (Tools.Tests) scans all 104 stack
assemblies and fails if an address, identity or path parameter of a
`[LoggerMessage]` method is unclassified.
- `DataClassificationLoggingTests` (Core.Tests) pins erasing and HMAC
redaction, pass-through, the plain-factory drop, and both stack
defaults.

Local results on net10.0:

| Suite | Passed | Failed |
|---|---|---|
| Core.Tests | 6,489 | 0 |
| Client.Tests | 3,231 | 0 |
| Tools.Tests | 7,210 | 0 |
| PubSub.Tests | 1,536 | 0 |
| Core.Diagnostics.Tests | 571 | 0 |
| Configuration.Tests | 241 | 0 |

- **Server.Tests:** the full run predates the final test fixes and had
12 failures. All 12 were in the fixtures fixed afterwards, which now
pass: RoleStateBinding (45/45) and the FileDirectory and
compatibility-logging fixtures (74/74 in total). The full Core.Tests and
Server.Tests runs were not repeated after those fixes.
- **Builds:** `UA.slnx` builds with 0 errors for net10.0 and net48, and
the whitespace check passes.
- **NativeAOT:** not confirmed locally, because the machine has no C++
linker. A trimmed publish of `Opc.Ua.Aot.Tests` reported no trim or AOT
warnings from the stack, the new packages or the generated logging code;
its only errors come from the TUnit test framework (`FSharp.Core` not
found). The `aot-test` CI job has to confirm the native build.

🤖 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>
2026-10-21 09:47:07 +00:00
5899354441 Clarify documentation, migration guidance, and SDK selection (#4823)
# Description

Remove stock phrasing and correct misleading or inconsistent technical
documentation. The initial two passes covered 367 Markdown files,
followed by an interactive review of 169 editorial flags. The U5
interpretation originally deferred in that review has since received the
upstream Mantis answer recorded by #4843; remaining server/spec
follow-ups stay explicit in the CTT log.

- Correct package responsibilities, migration steps, API names,
ownership, protocol descriptions and links. Keep bundled migration
guides synchronized. Preserve historical conclusions as historical
records with separate re-check notes.
- Provide at least two rendered TOC levels throughout `docs/`. Convert
appropriate architecture figures to Mermaid without changing code
examples, transcripts or historical quotations.
- Address the October 19 feedback with concrete setup/migration
instructions, corrected certificate paths and PubSub names, shorter
repeated caveats, consistent XML comments, and a full review of the
Concepts guide. Retain #4839's API corrections.
- Integrate #4841's runtime/performance guidance and review
incoming/recent prose. Preserve dated measurements rather than claiming
new benchmark runs; clarify allocation ratios, startup-only processor
settings, JIT warm-up and the benchmark commit's scope. Missing raw
measurement artifacts and repeat ranges remain tracked by #4842.
- Merge master through `e8c5d800a` in `32c6394b7`. Resolve the CTT log
conflict using the updated Mantis states and OPC answers, retaining
historical framing and re-checks for C28/C32/C43.
- Address the latest naming/index feedback in `adb5bf693`: rename the
guides to `docs/WotCon.md` and `docs/WotNodeSetConversion.md`; move the
provider and DataType-declaration contracts into the conversion guide
and remove `docs/WoT.md`. Rename `samples/DI` to `samples/Di` and
`samples/Isa95` to `samples/ISA95`. Add content-index READMEs to Di,
FluentApi, ISA95, Lds and PubSub; update solution entries, project
references, Docker/CI paths, test fixture paths and documentation links
with exact casing.

**WoT guide scope.** This branch retains the detailed conversion guide
that #4758 had replaced with an anchor stub. Conversion limits, verified
document sets, preservation rules and provider ownership now live
together in `WotNodeSetConversion.md`; binding/runtime guidance remains
in `WotBindings.md` and `WotCon.md`. The design-rationale/test-ledger
prose was shortened, the oversized argument-table cell moved into its
section, and existing anchors preserved.

**SDK policy is a separate, explicit user-directed change.**
`global.json` sets a stable minimum of `10.0.400`, `rollForward:
latestMajor`, and `allowPrerelease: false`. Newer major SDKs are
permitted; CI's explicit SDK pins are unchanged. README, GettingStarted,
DeveloperGuide and Recycling instructions reflect the possible analyzer
differences. The image-contract regression fix accepts compatible stable
SDK images instead of requiring exact equality with the floor.

The latest four rename/index threads are resolved after publication.
Three older threads remain open: universal obsolete-removal timing,
unconditional nonce-strength wording, and a blanket Vision NativeAOT
claim. Existing implementation/evidence constraints are explained in
their inline replies. The two-level TOCs remain as requested.

## Validation

- `git diff --check` and staged diff checks: passed. The untouched
upstream AutoID NodeSet retains its provenance bytes, including two
existing license-header trailing spaces.
- `.\.azurepipelines\validate-migration-plugin.ps1`: passed; 15 bundled
migration documents and manifests synchronized.
- Documentation/path validation: **101** documents retain two rendered
TOC levels (**2,228** entries), with idempotent formatting. **52** new
relative links and their anchors passed exact-case checks. No tracked
references to the old guide/sample paths remain. Both relocated
provider-contract sections are preserved in the destination.
- `dotnet sln UA.slnx list`: passed; shows the renamed Di/ISA95 projects
and the existing FluentApi/Lds/PubSub projects. Updated solution and
pump ProjectReference paths also passed exact-case existence checks.
- Latest focused commands, run from the repository root:

```powershell
# Passed: 61 channel send-order and reconnect tests.
dotnet test tests\Opc.Ua.Core.Tests\Opc.Ua.Core.Tests.csproj -p:CustomTestTarget=net10.0 --filter 'FullyQualifiedName~UaSCBinaryChannelSendOrderTests|FullyQualifiedName~TcpReconnectKeyAgreementTests' --verbosity minimal

# Passed: 5 pump datasheet tests; builds the renamed pump sample.
dotnet test tests\Opc.Ua.Di.Tests\Opc.Ua.Di.Tests.csproj -p:CustomTestTarget=net10.0 --filter 'FullyQualifiedName~PumpDatasheetConformanceTests' --verbosity minimal

# Passed: 4 repository NodeSet path/round-trip tests, including the moved pump models.
dotnet test tests\Opc.Ua.Types.Tests\Opc.Ua.Types.Tests.csproj -p:CustomTestTarget=net10.0 --filter 'FullyQualifiedName~RepositorySourceSurvivesSerializedLinkedPartitionsAsync' --verbosity minimal

# Passed: build, zero warnings/errors.
dotnet build samples\ISA95\MinimalIsa95Server\MinimalIsa95Server.csproj -p:CustomTestTarget=net10.0 -f net10.0 --verbosity minimal

# Passed: dependent sample-test project build, zero warnings/errors.
dotnet build tests\Opc.Ua.WotCon.Samples.Tests\Opc.Ua.WotCon.Samples.Tests.csproj -p:CustomTestTarget=net10.0 -f net10.0 --verbosity minimal
```

Earlier focused integration checks passed 2,620 tests across AutoID,
event queues/scheduling, source generation, MCP/package profiles and
WoT. Both reviewed Mermaid diagrams rendered locally without network
access. These checks are not a full solution run.

The full solution has **not** been rerun for this review. Earlier
`dotnet test UA.slnx -p:CustomTestTarget=net10.0 --verbosity quiet` did
not pass: three AOT projects rejected VSTest and 20 Vision tests failed
during graphics initialization with `OutOfMemoryException`. Remaining
long-running tests were stopped at the user's request. A focused Vision
rerun passed 558 tests and reproduced those 20 failures. The earlier
`dotnet test UA.slnx -p:CustomTestTarget=net48 --verbosity quiet` was
blocked by the former SDK pin and was not retried after the policy
change. The user approved proceeding with those limitations documented;
current remote CI is not claimed green.

## Related Issues

- Follow-up #4842 tracks the requested 2.0.2+ benchmark/tuning and
diagnostic-tool documentation refresh. #4841 supplies documentation and
earlier results, not all newly requested measurements.
- #4758 explains the conversion guide's earlier consolidation; the
current provider contracts are now consolidated into
`WotNodeSetConversion.md`.
- #4839 supplies the reviewed API corrections; #4843 supplies the
updated CTT issue record.
- The original prose cleanup and SDK policy were user-requested without
a source issue. No new runtime architecture is introduced by these
documentation edits.

## 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.
- [ ] 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: Roman Ettlinger <romanett98@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 10:10:55 +02:00
Klaus LandsdorfandMarc Schier 0bd7aaaad6 AutoID: keep every reader reachable from DeviceSet, and let a reader be a component of a machine (#4853)
# Description

Follow-up to the AutoID companion (#4825). Two changes to where the
AutoID server places its readers:

1. **`IAutoIdReaderBuilder.WithParent` is made conformant with OPC
10000-100 4.9.** Until now it placed a reader below another node
*instead of* `DeviceSet`, and the docs suggested "a component of a
machine" as the use case. A machine in the Machinery `Machines` folder
is not reachable from `DeviceSet`, so such a reader could not be located
from `DeviceSet`, which DI 4.9 requires for every `ComponentType`
instance (every AutoID device type derives from DI `DeviceType`). Now
the reader is still placed below the parent (`HasComponent`, unchanged),
and:
- when `DeviceSet` already reaches the parent through hierarchical
references (for example another reader, or any device it organizes),
nothing else is added: the reader is a component of a composed device
and DI 4.9 wants "only the root instance" referenced from `DeviceSet`;
- otherwise `DeviceSet` also organizes the reader (`Organizes`, both
directions), so it stays locatable.
2. **New opt-in `IAutoIdReaderBuilder.AsComponentOf(BaseObjectState
machine)`**, available only when AutoID is composed with Machinery
(`AddAutoIdToMachinery`, or a DI owner that loads the Machinery model).
It makes the reader an identifiable component of the machine per OPC
40001-1 11:
- the reader is placed below the machine's `Components` add-in
(`HasComponent`, as `MachineComponentsType` declares for `<Component>`);
- it gets the `2:Identification` add-in (`HasAddIn`) typed
`MachineryComponentIdentificationType`, which holds no copies: it
references the reader's own DI nameplate properties with `HasProperty`
(`Manufacturer`, `SerialNumber`, and those of `Model`,
`ManufacturerUri`, `ProductCode`, `HardwareRevision`,
`SoftwareRevision`, `DeviceRevision`, `DeviceClass`,
`ProductInstanceUri` that are set, plus `AssetId`/`ComponentName` when
published; `DeviceManual` and `RevisionCounter` are left out as 40001-1
10.2 recommends);
- the reader stays a device: the same `DeviceSet` rule as for
`WithParent` applies with `Components` as the parent, so in the usual
case (a machine in `Machines` that `DeviceSet` does not reach)
`DeviceSet` organizes it directly. It is not added to `Machines`.
- Checked before anything is registered, each failing with
`Bad_ConfigurationError` and a message naming the way out: the server
has no Machinery model (names `AddAutoIdToMachinery`), the machine is
not a node of the AutoID owner, the machine has no `Components` add-in
(names `WithComponents`), or the reader nameplate lacks `Manufacturer`
and/or `SerialNumber` (mandatory in `MachineryItemIdentificationType`).
`WithParent` and `AsComponentOf` exclude each other
(`InvalidOperationException`).

Default behaviour is unchanged: a reader built without
`WithParent`/`AsComponentOf` is organized by `DeviceSet` and not added
to `Machines`. Rollback on failure covers the new references: a failed
build removes the `DeviceSet` reference, the component and its add-in,
so the name can be used again.

## Specification basis

| Statement | Source |
| --- | --- |
| "The DeviceSet Object is the starting point to locate Devices. It
shall either directly or indirectly reference all instances of a subtype
of ComponentType with a Hierarchical Reference. For complex Devices that
are composed of various components that are also Devices, only the root
instance shall be referenced from the DeviceSet Object. The components
of such complex Devices shall be locatable by following Hierarchical
References from the root instance." | OPC 10000-100 (DI) 1.05.0, 4.9 |
| AutoID device types are "Subtype of DeviceType defined in OPC
10000-100". OPC 30010 does not mention `DeviceSet`, Machinery or
`Machines`. | OPC 30010 (AutoID) 1.01, 6.1 Table 7 |
| "Each Machine providing this building block provides the Components
Object, which directly references all identifiable components of a
Machine." / "Note: That does not preclude that a Machine is organizing
its components in various other hierarchies as well." | OPC 40001-1
(Machinery) 1.04.2, 11.1 |
| "The MachineComponentsType provides HasComponent References to all
identifiable components of a Machine"; Table 27: `0:HasComponent Object
<Component> 0:BaseObjectType OP`; Table 28: `<Component> 0:HasAddIn
Object 2:Identification MachineryItemIdentificationType M`; "As all
identifiable components shall contain the Identification Object". | OPC
40001-1 1.04.2, 11.2 |
| `MachineryItemIdentificationType`: `2:Manufacturer` M,
`2:SerialNumber` M, all other members O. | OPC 40001-1 1.04.2, 8.3 Table
16 |
| `MachineryComponentIdentificationType` is the component subtype of
`MachineryItemIdentificationType` (adds `2:DeviceRevision`); "It is not
recommended to use the optional Properties 2:RevisionCounter and
2:DeviceManual"; a missing component `2:ProductInstanceUri` "shall be
omitted". | OPC 40001-1 1.04.2, 10.2 |
| "it is recommended to keep this approach and let several References
lead to the same Node, rather than duplicating the Node" / Robotics
example: identification Properties "are only referenced from the
Identification functional group". | OPC 40001-1 1.04.2, 7.3 and Annex
B.2 (informative) |
| "It is expected that domain-specific companion specifications using
this specification will define what assets of the domain in what usage
are considered to be Machines or components of Machines." | OPC 40001-1
1.04.2, 11.1 (Note) |
| Organizes "can be used to span multiple hierarchies"; more than one
hierarchical path to a Node is allowed. | OPC 10000-3 1.05, 7.11, 7.5 |

**SDK decisions** (not specified by OPC 30010, which predates
Machinery):

- A reader is never added to `Machines`: no specification classifies an
AutoID device as a machine (40001-1 11.1 leaves that to the domain
specifications, AutoID is silent).
- A reader becomes a component of a machine only when the application
asks for it (`AsComponentOf`).
- `WithParent` keeps its parent and adds the `DeviceSet` reference
rather than rejecting parents outside `DeviceSet` (see below).
- The component `Identification` references the reader's nameplate
instead of copying it.
- AutoID does not add Machinery conformance units; the Machinery owner
advertises them for what its builders created.

### Why `WithParent` adds a reference instead of rejecting

DI 4.9 asks for reachability, not for a particular parent, and OPC
10000-3 allows several hierarchical paths to one node. Adding
`Organizes` from `DeviceSet` satisfies the "shall" while keeping the
parent the application asked for, and it is the same reference type
`DeviceSet` already uses for every reader (and the DI node manager for
every device). Rejecting such parents with `Bad_ConfigurationError`
would break existing callers that hang a reader below a machine or
folder. When `DeviceSet` already reaches the parent, no reference is
added, so the "only the root instance" rule of 4.9 holds for composed
devices. Reachability is decided once when the reader is built, by
following the forward hierarchical references of the owner's nodes.

### Why `AsComponentOf` keeps the reader referenced from `DeviceSet`

Machinery does not require machines to be in `DeviceSet` (the stock
builders put them in `Machines` only), so a reader that is only below
`Components` would again be unreachable from `DeviceSet`. The same
reachability rule as for `WithParent` applies: if the machine is itself
reachable from `DeviceSet` (for example a machine that is also a DI
device organized by `DeviceSet`), the reader is reached through it and
gets no direct reference, otherwise `DeviceSet` organizes it.

### Shape of `AsComponentOf`

It takes the machine's `BaseObjectState`, like `WithParent(NodeState)`
and `IMachineryBuildContext.BindMachine(TState, ...)`; with the stock
builders that is `IMachineHandle<TState>.State`. It requires the
machine's `Components` add-in to exist
(`IMachineBuilder.WithComponents`, or a companion-declared one bound
with `WithComponents(components, ...)`) instead of creating one, so the
Machinery builders stay the only place that creates Machinery building
blocks (`MachineryBuildingBlocks` organization, facets). The reader's
NodeState parent is the `Components` object, so the DI node manager
attaches it with `HasComponent` and the existing registration and
rollback code applies unchanged.

## Behaviour change for existing callers

- `WithParent(parent)` with a parent that `DeviceSet` does not reach
(for example a machine in `Machines`, or an application folder): the
reader is now *also* browsable from `DeviceSet` (one extra `Organizes`
reference, both directions), and an
`AutoIdClient.EnumerateReadersAsync()` (which browses `DeviceSet`) now
finds it. Its parent, NodeId and `HasComponent` reference are unchanged.
- `WithParent(parent)` with a parent `DeviceSet` reaches (for example
another reader): unchanged.
- Default placement: unchanged.
- `IAutoIdReaderBuilder` gains a member. The interface is implemented
only inside the package (instances come from `AutoIdRuntime.Reader`),
and the package is new in 2.0.

## Changes

- `src/Opc.Ua.AutoID.Server/AutoIdReaderPlacement.cs` (new, internal):
`DeviceSet` lookup, the reachability check, the `Organizes` reference
and its removal, `Components` resolution and validation, the nameplate
check, and the `Identification` add-in.
- `src/Opc.Ua.AutoID.Server/AutoIdRuntime.cs`: the builder's nameplate
delegate runs once (validated before anything is registered, then copied
into the DI builder); `AsComponentOf` resolves `Components` as the
parent before the device is created; after registration the add-in is
added and the `DeviceSet` reference kept; rollback removes the
reference.
- `src/Opc.Ua.AutoID.Server/Builders/IAutoIdReaderBuilder.cs`,
`AutoIdReaderBuilder.cs`: `AsComponentOf`, mutual exclusion with
`WithParent`, XML docs stating the exact behaviour.
- `docs/AutoID.md`: new "Placement" subsection under "Hosting readers"
(what DI 4.9 and Machinery 11 require, what is an SDK decision, a table
per builder call, an example), the composition table rows for readers
and `Machines`, the test fixture table.
`src/Opc.Ua.AutoID.Server/NugetREADME.md`: a short paragraph.
- Tests (below).

## Tests

- `AutoIdReaderLifecycleTests` (stand-alone owner):
`ReadersCanHangBelowAnExplicitParentAsync` now also asserts that
`DeviceSet` does not organize a reader below another reader;
`AReaderBelowAParentOutsideTheDeviceSetIsStillOrganizedByItAsync`;
`AFailedCreationBelowAnOutsideParentLeavesNoDeviceSetReferenceAsync`
(rollback); `AStandaloneReaderCannotBeAComponentOfAMachineAsync`
(`Bad_ConfigurationError` naming `AddAutoIdToMachinery`); argument
checks for `AsComponentOf(null)` and both call orders of
`WithParent`/`AsComponentOf`.
- `AutoIdCompositionTests` (Machinery owner, in process):
`AReaderBecomesAComponentOfAMachineAndStaysADeviceAsync` (parent is
`Components`, `HasComponent`, `DeviceSet` organizes it, `Machines` does
not, `Identification` with `HasAddIn` and the component type,
`HasProperty` to the reader's own `Manufacturer`/`SerialNumber`/`Model`
nodes, no `DeviceManual`, owned subtree);
`AComponentNeedsAMachineWithComponentsAndRegistersNothingOtherwiseAsync`;
`AComponentNeedsTheMandatoryIdentificationAsync`;
`AComponentNeedsAMachineOfTheOwnerAsync`;
`AFailedComponentCreationIsRolledBackAsync`.
- `AutoIdPlacementEndToEndTests` (new, hosted Machinery + AutoID over a
real session): a default reader, a reader below that reader, a reader
`AsComponentOf` a machine and a reader `WithParent` the machine.
Browsing `DeviceSet` finds the three roots and not the nested reader,
the outer reader reaches the nested one;
`MachineryClient.DiscoverMachinesAsync` returns only the machine and
`Machines` contains no reader;
`MachineryClient.EnumerateComponentsAsync` lists only the component
reader, `ReadIdentificationAsync` reads `Manufacturer`, `SerialNumber`,
`ProductCode` through the add-in, which is `HasAddIn`-referenced and
typed `MachineryComponentIdentificationType` and whose `HasProperty`
targets are the reader's own nameplate nodes;
`AutoIdClient.EnumerateReadersAsync` finds the three roots.
- The existing default assertions
(`TheReadersShareTheDeviceSetWithThePumpAndStayOutOfTheMachinesFolderAsync`,
`TheMachineryAndPumpsClientsStillFindTheirInstancesAsync`,
`ComposedReadersLiveBelowTheSharedDeviceSetAsync`) are unchanged.
- Fail-without: with the runtime wiring reverted to master (builder
surface kept so the tests compile), 10 of the new tests fail: all
`AsComponentOf` cases, both `WithParent` cases outside `DeviceSet` and
the three session tests that look for the component or the extra
`DeviceSet` reference. Removing only the rollback of the `DeviceSet`
reference makes both rollback tests fail. `NoReaderBecomesAMachineAsync`
and the existing default tests pass with and without the change, as
intended.

Local results (macOS arm64, SDK 10.0.402, head `238ed9b69` on master
`9bfc780e1`; the same three commits are now based on master `ac24123dd`
as `c6d70909b`, no AutoID file changed in between):
`Opc.Ua.AutoID.Tests` net10.0 318/318 passed; `Opc.Ua.AutoID.Server` all
TFMs and the test project with `CustomTestTarget=net48` build with 0
warnings, 0 errors (net48 tests run on Windows CI; there is no mono host
here). `dotnet format whitespace/style/analyzers --verify-no-changes` on
the changed files: no new findings beyond the conventions of the
surrounding test files.

## Commits

1. `fix(autoid): keep every reader reachable from DeviceSet`
2. `feat(autoid): make a reader a component of a machine`
3. `docs(autoid): document where readers are placed`

The branch is based on OPCFoundation/master `ac24123dd` (#4852, the last
master commit with a green build). Fork CI was not repeated on this
head: earlier fork runs of this branch failed only in test projects
outside this PR (the master build was broken by the NUnit 5 migration
#4840 and fixed in #4852; master is currently broken again in
`Opc.Ua.Core.Tests` by #4849). The upstream checks of this PR test it
against the current master.

## Related Issues

- Follow-up to #4825 (tracking issue #4824).
- #4823 (open) edits prose in other paragraphs of `docs/AutoID.md`
(table of contents, the DI-owner paragraph, providers). This PR adds a
subsection and changes two rows of the composition table and one row of
the test table; no hunk overlaps. The table of contents is deliberately
not touched here because #4823 adds the subsection entries for "Hosting
readers"; the "Placement" entry can be added there or after both are
merged.

## 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. (Local build of the changed projects: 0 warnings, 0
errors.)
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed. (Locally
the AutoID test project on net10.0; net48 is built but runs only on
Windows CI.)
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings. (Pending the upstream checks; see the CI note
above.)
- [x] I have addressed **all** PR feedback received.

---------

Co-authored-by: Marc Schier <marcschier@users.noreply.github.com>
2026-10-21 05:24:38 +00:00
Klaus LandsdorfandMarc Schier a51f08ad6f AutoID client: read a device's status without browsing (#4854)
# Description

A follow-up to #4825. It adds
`AutoIdClient.RefreshDeviceAsync(AutoIdDeviceSnapshot,
CancellationToken)`, which reads the variables of a reader device that
change at runtime again through the NodeIds a snapshot already carries.
It makes one Read and no Browse.

## Why

`ReadDeviceAsync` browses the children of the device on every call. OPC
30010 makes most device variables and methods optional, so the browse is
how the client finds out what this device publishes.
`AutoIdReadOptions.StatusOnly` only narrows the Read. This is the point
the Copilot review raised on #4825
([discussion](https://github.com/OPCFoundation/UA-.NETStandard/pull/4825#discussion_r4308032841)).
1458af9e9 documented the behaviour, but a client that polls the status
of N readers still pays N Browse and N Read requests per cycle. One Read
per reader would do.

`AutoIdDeviceSnapshot` already carries what a poll needs:
`DeviceStatusId`, `LastScanDataId`, `LastScanTimestampId`,
`DeviceLocationId` and `Methods`, all taken from that browse.
`RefreshDeviceAsync` uses them.

## Change

- **`RefreshDeviceAsync(snapshot)`** reads `DeviceStatus` and, when the
device publishes them, `LastScanData`, `LastScanTimestamp` and
`DeviceLocation`. That is one Read of one to four variables and no
Browse. It returns a copy of the snapshot (`with`), and every other
member, including `Methods` and the NodeIds, is unchanged.
- **Stateless.** The client keeps no cache. The caller holds the
snapshot, as with the other companion clients
(`PumpsClient.ReadPumpAsync`, `ScalesClient.ReadSnapshotAsync`, and
Robotics' `RefreshAsync` on its handles). A poller calls
`ReadDeviceAsync` once and then passes the snapshot to
`RefreshDeviceAsync` on every cycle.
- **Stale snapshot.** The snapshot is not validated against the server.
If it no longer matches the device (the device or a variable was
deleted, or the server assigned other NodeIds after a restart), the
status of the Read surfaces:
- a bad status for `DeviceStatus` (for example `BadNodeIdUnknown`) is
thrown as `ServiceResultException` with that status;
- a bad status for `LastScanData` is returned in `LastScanStatus` with a
null value;
- a bad `LastScanTimestamp` or `DeviceLocation` leaves that member at
its default.

In each case the caller reads the device again with `ReadDeviceAsync`.
That also picks up optional variables and methods the device published
after the snapshot was taken.
- **Arguments.** A null snapshot throws `ArgumentNullException`. A
snapshot without a `DeviceStatus` NodeId (not obtained from
`ReadDeviceAsync`) throws `ArgumentException`. Neither one makes a
service call.
- **Docs.** Covered in the XML docs of `ReadDeviceAsync`,
`RefreshDeviceAsync`, `AutoIdReadOptions` and `AutoIdDeviceSnapshot`, in
`docs/AutoID.md` and in the client `NugetREADME.md`. The wording
maintainers gave `AutoIdReadOptions` in 1458af9e9 stays as it is, with
one added sentence that points to `RefreshDeviceAsync`. `docs/AutoID.md`
states the cost of each call:

| Call | Network cost |
| --- | --- |
| `ReadDeviceAsync(reader)` | One Browse of the device's children
(BrowseNext only if the server pages the result), one Read of every
published variable |
| `ReadDeviceAsync(reader, AutoIdReadOptions.StatusOnly)` | One Browse,
one Read of two variables (`DeviceStatus`, `DeviceName`) |
| `RefreshDeviceAsync(snapshot)` | One Read of up to four variables, no
Browse |

The change is additive. No existing member changes behaviour, and there
are no new dependencies. The MCP `autoid_read_device` tool still uses
`ReadDeviceAsync`, because a single MCP tool call has no snapshot to
refresh.

## Tests

`tests/Opc.Ua.AutoID.Tests`:

- `AutoIdRefreshDeviceTests` (new, 7 tests) uses a `MockBehavior.Strict`
`ISession` with `VerifyNoOtherCalls`, so any Browse, BrowseNext or
second Read fails the test:
- one Read of exactly the four snapshot NodeIds (Value attribute,
`maxAge` 0), with the values mapped and the other members (`Methods`,
`DeviceName`, NodeIds) unchanged;
- a device without the optional variables is refreshed with a
one-variable Read;
- a stale `DeviceStatus` NodeId throws with the Read's status
(`BadNodeIdUnknown`);
- a stale `LastScanData` surfaces `BadNodeIdUnknown` in
`LastScanStatus`, and a bad timestamp or location resets those members;
  - a result-count mismatch throws `BadUnexpectedError`;
  - null and invalid snapshots are rejected without a service call;
  - cancellation reaches the Read.
- `AutoIdReaderEndToEndTests` (2 new tests against the hosted AutoID
server over opc.tcp):
- `ARefreshedSnapshotCarriesTheCurrentStatusAsync`: a reader is read
once, then faulted on the server. The refreshed snapshot shows `Error`
and the methods from the original browse.
- `ARefreshOfAStaleSnapshotSurfacesTheStatusOfTheReadAsync`: snapshots
whose NodeIds the server does not know (as after a deletion).
`LastScanStatus` carries `BadNodeIdUnknown`, and an unknown
`DeviceStatus` NodeId throws `BadNodeIdUnknown`.
- **Fail-without (mutation checks):**
- With a Browse of the device's children added to `RefreshDeviceAsync`,
every strict-mock test that reaches the Read fails.
- With a bad `LastScanData` status masked to `BadNotFound` (the
`ReadDeviceAsync` convention for optional variables),
`AStaleOptionalVariableSurfacesItsStatusWithoutThrowingAsync` and the
end-to-end stale-snapshot test fail.
  - Without the method, the tests do not compile.

## Verification (local, macOS arm64, SDK 10.0.402 from `global.json`)

Head 2118c2439 on OPCFoundation/master ac24123dd (#4852), which includes
the NUnit 5 migration (#4840). The new tests follow the NUnit 5
conventions in `docs/DeveloperGuide.md`: `Assert.ThrowsAsync` and
`Assert.MultipleAsync` are awaited, and the enclosing test returns
`Task`.

- `Opc.Ua.AutoID.Tests` on net10.0: 315 passed, 0 failed (9 of them
new).
- `Opc.Ua.AutoID.Tests` builds with `-p:CustomTestTarget=net48` with 0
warnings and 0 errors. Running net48 needs Windows, so the fork CI
covers it.
- `dotnet format` whitespace, style and analyzers at `--severity info`,
verify-no-changes on the changed `.cs` files: no findings in the new
code.
The branch is based on OPCFoundation/master `ac24123dd` (#4852, the last
master commit with a green build). Fork CI was not repeated on this
head: earlier fork runs of this branch failed only in test projects
outside this PR (the master build was broken by the NUnit 5 migration
#4840 and fixed in #4852; master is currently broken again in
`Opc.Ua.Core.Tests` by #4849). The upstream checks of this PR test it
against the current master.

## Related Issues

- Follow-up to #4825 (tracking issue #4824). Resolves the polling-cost
point of the Copilot review on `AutoIdReadOptions` in #4825.
- #4823 (open) edits prose in other sections of `docs/AutoID.md`. There
is no textual overlap with the client section changed here.

## 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. (Local build of the changed projects: 0 warnings, 0
errors.)
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed. (Locally
the AutoID test project on net10.0; net48 is built but runs only on
Windows CI.)
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings. (Pending the upstream checks; see the CI note
above.)
- [x] I have addressed **all** PR feedback received.

---------

Co-authored-by: Marc Schier <marcschier@users.noreply.github.com>
2026-10-21 05:11:03 +00:00
Marc Schier 2836da7ec8 Fix sessionless ResultTransfer ownership bypass and other issues. (#4834)
# Description

Correct ownership, endpoint validation, policy propagation, subscription
delivery, file handling and generated-model behavior across the stack,
tools and samples. Add focused regression tests and update the
corresponding guides and sample documentation.

### Previously included corrections

- Require an active, matching session to generate or access transient
ResultTransfer file handles. Cover both sessionless generation and reads
against a handle owned by an active session, using live session owners
in the Machinery tests.
- Keep physical filesystem operations within their configured mount and
apply the same confinement checks throughout file operations.
- Validate LDS semaphore paths consistently during server registration
and registration-store maintenance.

### Individual issues addressed

| # | Component | Issue and correction |
|---|---|---|
| 1 | AI jobs and transfers | Jobs and transfers did not consistently
retain their creator's identity and session. Apply ownership checks to
descendants, file handles, subscriptions and execution, and recheck
deployment permissions before dispatch. |
| 2 | Password-management client | Password-bearing calls could use
signing-only channels or follow a replacement connection. Capture an
encrypted transport generation and keep dispatch bound to it; preserve
ordinary non-password edits. |
| 3 | GDS and certificate-push clients | Connections omitted hostname
verification. Validate the original configured DNS name or IP address
before session activation. |
| 4 | Certificate push | Private-key uploads could use signing-only
calls. Require an encrypted, generation-bound dispatch while preserving
certificate-only updates and restoring the caller's identity afterward.
|
| 5 | MQTT client | The .NET Framework custom-trust callback could
discard TLS hostname, certificate-availability and usage errors. Retain
those checks while supporting configured issuer trust. |
| 6 | DTLS handshake | Certificate trust was not tied to the configured
peer address. Match the certificate's DNS or IP identity to the intended
endpoint. |
| 7 | Replicated address spaces | Serialized nodes lost per-node and
descendant permission metadata. Preserve role permissions, user-role
permissions and access restrictions, including updates to existing
replica nodes. |
| 8 | MCP PubSub imports | Imports without groups skipped checks on the
effective broker destination. Check the actual endpoint before resolving
credentials or activating the connection. |
| 9 | Reference-server accounts | A disabled username account could
authenticate again with its previous roles. Reject disabled accounts
during fresh authentication. |
| 10 | Console reference client | The sample disabled hostname checks
when connecting to an otherwise trusted peer. Retain the original
requested host and validate it before activation. |
| 11 | Capture replay and export | Replay and raw export could accept
non-capture files or include key material. Require complete PCAP/PCAPNG
containers and exclude key sidecars and decryption-secret blocks. |
| 12 | PubSub adapter fallback | The fallback client configuration
automatically accepted peer certificates. Apply normal trust and
hostname validation before activation or reads. |
| 13 | Kafka transport | Explicit TLS enablement was checked but did not
reach both final Confluent configurations. Use the same effective
protocol for validation, producers and consumers. |
| 14 | PubSub actions | Configuration rebuilds could widen a
connection-local action handler's scope. Preserve its original
connection association and reject mismatched dispatch. |
| 15 | PubSub readers | Reader-level message-protection settings were
accepted but ignored. Apply the effective group and reader settings
before accepting and publishing received values. |
| 16 | PubSub key groups | Different `SecurityGroupId` values could
share one connection-wide key context. Reject mixed groups on a
connection and require separate connections for distinct groups. |
| 17 | MCP session registrations | Session-name replacement or reuse
could retain another registration's credential bindings. Bind access to
the registration, caller and authenticated endpoint, and preserve that
identity when borrowing sessions. |
| 18 | Viewer HTTP host | Requests reached the viewer's OPC UA session
without consistent Host, Origin and port checks. Validate the request
destination and browser origin before using the session, while retaining
supported local MCP clients. |
| 19 | Vision file operations | File operations did not consistently
apply protected-file checks, and remote handles lacked caller and
connection context. Apply the shared checks and use host-side opaque
handles bound to caller, target and generation. |
| 20 | DTLS record integrity | The integrity-only MAC used a truncated
sequence number. Authenticate the full reconstructed sequence number and
epoch so records remain distinct across rollover. |
| 21 | AMB client sample | Connection setup omitted hostname validation.
Preserve the originally requested host and verify the peer identity
before activation. |
| 22 | Robot sample writes | The R2 `SpeedOverride` callback could
target R1 state. Bind each callback to its own robot and apply that
robot's write policy. |
| 23 | AI result properties | Optional result children inherited
remotely writable defaults. Preserve their declaration restrictions and
keep server-produced results read-only. |
| 24 | WSS peer-trust updates | Existing channels did not consistently
respond to peer-trust changes. Revalidate complete chains for forward
and reverse WSS channels and close only those that no longer validate. |
| 25 | GDS revocation checks | Caller-provided certificates could cause
issuer retrieval from embedded URLs. Resolve validation from configured
local trust, issuers and CRLs without fetching caller-provided
locations. |
| 26 | GMS observations | Published correction observations retained
writable model defaults. Reject remote writes while preserving
publisher-side updates. |
| 27 | Production-dataset monitoring | Subscription admission and
delivery could use the reporter's context instead of each subscriber's
permissions. Evaluate the subscriber at admission and on every delivery,
including cancellation and disposal paths. |
| 28 | WoT MQTT destinations | Hostless MQTT forms could imply a
localhost destination before loopback checks. Reject them before
credential lookup or connection attempts and validate the effective
endpoint. |
| 29 | XRegistry opaque reads | Content-ID reads and notifications did
not consistently honor encrypted-read requirements or the currently
accepted transport. Recheck the bound endpoint and reject transport
downgrades before rebinding, including when protected values are already
queued. |
| 30 | PubSub capture dissection | Key loading did not consistently
check the configured capture-root boundary. Check each actual key path
immediately before opening it. |
| 31 | PubSub publication queues | Imported WriterGroup queues were not
fully checked against publish-topic grants. Check the destination
actually used by each group. |
| 32 | MCP PubSub mutations | A caller-supplied mode could select
subscribe grants for an operation that publishes. Derive the required
direction and parent context from the actual mutation. |
| 33 | Generated write masks | Model import and generation could widen
an explicit `UserWriteMask`. Preserve it through import, copy, merge,
inheritance and dependency metadata. |
| 34 | Generated method flags | An explicit `UserExecutable=false` could
be lost. Preserve its specified state and resulting method behavior
through the same model paths. |
| 35 | Generated role identifiers | Unresolved role identifiers could
cause permission entries to be omitted. Preserve namespace-qualified
role IDs and report a diagnostic when a policy cannot be represented. |
| 36 | Aggregation-server defaults | The `localhost` default did not
reliably constrain listener binding. Use numeric loopback for the
default endpoint and command-line fallback. |
| 37 | Certificate-validation cache | Cached results could skip current
leaf or issuer validity checks. Recheck validity windows on cache hits
and keep hostname validation independent of cached chain trust. |
| 38 | Software-update folders | Revision names and marker contents
containing dot segments could select paths outside the software root.
Normalize and check every derived path against that root. |
| 39 | USDA arrays | Array elements were not encoded for their string or
asset context. Escape them as data and preserve valid composition and
round-trip behavior. |
| 40 | Adapter hot reload | A changed dataset definition with the same
name could keep old fields, subscriptions or cached values active.
Compare complete immutable definitions and retire old state before
replacement. |
| 41 | Adapter index ranges | Subscription publication could ignore
`IndexRange` and return the complete value. Preserve the requested
projection and its status and timestamps; reject unsupported range
providers. |
| 42 | PubSub metadata and targets | Metadata from one connection could
change another connection's target mapping. Scope metadata to its
connection and provenance, and resolve targets using actual typed field
IDs rather than field names or unintended positional fallback. |
| 43 | Scales product locks | Product locks were not applied across
every remote mutation path. Apply them consistently to value writes,
filling calculations and related recipe/controller operations while
retaining permitted updates. |
| 44 | Event permission caching | Cached event decisions could outlive
identity, role or node-policy changes. Revalidate the current
subscriber, event type and source permissions instead of reusing a stale
decision. |
| 45 | WoT Modbus destinations | Hostless Modbus forms could imply
localhost before loopback checks. Reject them before connecting and
validate the effective endpoint. |
| 46 | MCP local PKI tools | Peer-trust mutations did not consistently
require the caller's `CertificateAdministration` grant. Check the
capability before trust and untrust operations. |
| 47 | Robot-control HTTP bridge | The bridge lacked consistent Host,
Origin and port validation and caller-specific session grants. Apply
those checks before borrowing the OPC UA session and retain supported
local clients. |
| 48 | Subscription transfers | Transferred subscriptions could retain
cached notifications after the destination's permissions changed.
Require authorization continuity between source and destination
identities and roles, invalidate stale retransmission data when
continuity fails, and preserve retransmission behavior for compatible
transfers. |

### Compatibility notes

- Replication uses the versioned NSRP v1 policy envelope. Upgrade peers
together and re-encode or reseed legacy topology from the authoritative
model; missing permission metadata is not inferred from old records.
- Integrity-only DTLS peers must be upgraded together for the full
sequence/epoch MAC. AEAD behavior is unchanged.
- Distinct PubSub key groups require separate connections.
- Custom sessions that cannot capture the actual encrypted transport,
and subscription providers that cannot honor a requested range, are
rejected rather than silently changing the requested behavior.
- Subscription transfers retain retransmission data only when the
destination authorization remains compatible with the source snapshot;
otherwise the old sequence numbers and cached data are invalidated.

## Related Issues

No separate tracking issue is linked. The individual corrections are
listed above.

## 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.
- [ ] 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-21 05:37:29 +02:00
Marc Schier 994d7b34da Fix CI test duration regressions (#4861)
# Description

Partition the long-running sample and WoT test suites into bounded,
non-overlapping CI shards, and give all workloads the standard 45-minute
test-execution budget. Account for setup/build overhead separately in
the job timeout so builds do not consume test timeouts. Remove the
former sample, integration, WoT, and server-specific timeout exceptions
while retaining complete test scheduling.

Focused matrix tests pass (25/25); full and PR matrix generation
succeeds at 238 and 98 jobs, respectively. PowerShell syntax parsing and
`git diff --check` pass. The full solution test suite was not run
locally.

## Related Issues

- Fixes #4858

## 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.
- [ ] 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-20 23:36:59 +00:00
Wolfgang ColsmanandMarc Schier 6d2c7c0c67 WoT: retire a generation's event bindings before releasing their sources (#4860)
Fixes the intermittent failure of
`WotEventModesLiveTests.NativeRetiringGenerationUsesCapturedActionsUntilDrainAsync`
(introduced with #4757), which currently fails on master and on
unrelated PRs, e.g. the master push run
[39410642994](https://github.com/OPCFoundation/UA-.NETStandard/actions/runs/39410642994)
and #4836's run
[39410362062](https://github.com/OPCFoundation/UA-.NETStandard/actions/runs/39410362062/job/118588752785):

```
Expected: BadEventIdUnknown [0x809A0000]
But was:  BadSecurityChecksFailed [0x80130000]
```

### Cause

`WotProjectionBindingRuntime.DisposeAsync` disposed the event sources
and channel slots (which disposes the captured `WotEventSource` binding)
before it cleared its `WotProjectedEventBinding`s. During a shadow
reload, the route registry resolves a Condition action's EventId across
generations, so a call arriving in that window still resolved on the
retiring binding and invoked through the disposed source:
`WotCapturedConditionAction.InvokeAsync` → `Source.Validate()` →
`BadSecurityChecksFailed`.

### Fix

- The runtime clears its event bindings right after cancelling its
lifetime, so no further action resolves on them, and waits until the
action calls already resolved on them have ended before disposing the
sources and slots.
- `TryResolveOwnAction` / `ResolveAction` hand out a call handle that
the Method handler holds until the invocation ends
(`WotProjectedEventBinding.WhenActionCallsCompletedAsync`). All touched
types are internal; no public API changes.

### Tests

- New
`WotRetainedConditionRefreshTests.RetiredBindingWaitsForAResolvedActionCallAndResolvesNoOtherAsync`
(both identity modes): a retired binding keeps a resolved call until it
is disposed and resolves no other call (`BadEventIdUnknown`).
- `NativeRetiringGenerationUsesCapturedActionsUntilDrainAsync` locally
(Windows, net10.0), 20 runs each: master 17 passed / 3 failed with
`BadSecurityChecksFailed`; this branch 20 / 20 passed.
- `WotRetainedConditionRefreshTests`, `WotPrivateEventCaptureTests`,
`WotEventCapacityOwnershipTests` (123) and `WotEventModesLiveTests` (74)
pass.

---------

Co-authored-by: Marc Schier <marcschier@users.noreply.github.com>
2026-10-20 17:17:03 +00:00
b72eb44a31 Redundancy: bound the Raft log with the engine's snapshots (#4836)
# Description

**Draft: builds on #4835 and waits for a RaftCs release** containing
marcschier/raft-cs#9, marcschier/raft-cs#10 and marcschier/raft-cs#11,
anticipated as 1.1.4. It contains the commit of #4835; review only the
second commit.

`RaftSharedKeyValueStore` can capture and restore its state (#4794), but
`IRaftConsensus` carries no log indices, so the store cannot bound the
RaftCs log, which grows without limit. This change uses the engine's
snapshots:

- `IRaftSnapshotConsensus` (`CommittedItems` with log indices and
snapshots, `CompactAsync`) and `RaftCommittedItem`.
`DefaultRaftConsensus` implements it over `RaftNode.CommittedItems`,
`CreateSnapshotAsync` and `CompactAsync`.
- `RaftSharedKeyValueStore` gains a constructor overload with a
compaction interval and an optional logger (the existing constructors
are unchanged) and `AppliedIndex`. Over an `IRaftSnapshotConsensus` it
applies the indexed stream. A snapshot on that stream (a lagging replica
installing the leader's snapshot, or the first item after a restart)
replaces the state and publishes the differences to watchers. Every
compaction interval it snapshots its state into the engine, which
discards the log behind it. A compaction that fails is logged and
retried at the next interval.
- The engine snapshot is a format marker followed by the snapshot
encoding of #4794, so it carries the command position: the commands
after it keep the positions they have on every replica, and
`CreateSnapshotAsync`/`RestoreSnapshotAsync` keep working over it. No
compaction runs while a store restored with `FromFirstCommand` still
skips the replayed commands its snapshot holds.
- A snapshot in another format stops the applier. Pending and later
proposals and watches then fail with its error instead of waiting.
- `AppliedIndex` is published once the state holds an item, before
watchers and the proposer learn of it.
- Registration: `RedundancyConsistencyOptions.RaftCompactionInterval`
(server), and overloads of `AddRaftClientSharedStore` and
`AddRedundantClientSharedStore` that take the interval (client). Zero,
the default, never compacts. The store's logger comes from the
registered `ITelemetryContext`, else `ILoggerFactory`.
- `HighAvailability.md` describes the compaction and warns that a
replica must not be rolled back to a release without engine snapshots
once the cluster compacted its log.

No public member is removed or changed.

## Related Issues

- #4794 (store snapshots), #4835 (RaftCs 1.1.4)
- marcschier/raft-cs#9, marcschier/raft-cs#10, marcschier/raft-cs#11

## 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.
(`RaftSharedKeyValueStoreCompactionTests`; registration tests in Server
and Client.)
- [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. (Redundancy
test projects on .NET 10 against a RaftCs build of the three pull
requests: Server 935, Client 135, Kubernetes 103, PubSub 45, Samples 17,
all pass; the Server and Client test projects build for net48.)
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings. (CI cannot restore RaftCs 1.1.4 until it is
released.)
- [x] 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-20 14:48:20 +00:00
romanettandClaude Opus 5.5 2ba86e3a3b Server: queue the items of an event on several threads (#4859)
# Description

E2 of the event-heavy server load investigation (follows #4837 and
#4845).

### Problem

After #4845 (each event is evaluated once per distinct filter), the
event consumer of the Server node still ran on one core.
`MonitoredNode2` started the delivery of an event to every item and
awaited `Task.WhenAll`. Unless a permission check completed
asynchronously, every delivery ran synchronously on the consumer thread,
so one thread queued every item of every event. Each event also
allocated an `IEventMonitoredItem[]` snapshot
(`ConcurrentDictionary.Values`) and a `List<Task>`/`Task[]` sized to the
item count.

### Change (`MonitoredNode2`, multi-consumer nodes such as the Server
node)

- **Collect on the consumer.** Items are enumerated straight from
`EventMonitoredItems`. The audit filter and the #4845 group lookup run
there too. The results go into an `(item, key, evaluation)` struct
buffer that is reused for every batch and cleared afterwards, so the
per-event snapshot array and the per-item task list are gone.
- **Consumer keeps up (no notification waiting in the channel).** It
queues the event itself and does not wake pool threads. It splits only
what is left of an event after 1 ms (a very large event).
- **Consumer is behind (notifications waiting).** It also takes up to 3
more waiting events for the same items. It splits the batch at once into
ranges of at least 32 items, at most `min(ProcessorCount, 8)`, with one
range on the consumer thread. Each range queues the events of the batch
to its items one after the other. An event for different items (for
example an audit event that only reaches secure sessions) ends the batch
and is processed next; a fence or data change ends it too.
- **Ordering.** Every item still gets its events in order. A range waits
for its items' pending (asynchronous) permission checks before it queues
the next event of the batch, and the next batch is read only after all
ranges finished. A delivery that waits for a permission check still does
not hold up the other items.

The node-wide barrier per event now applies per batch: while one item
waits for its permission check, items in other ranges may already get
the next event of the same batch.

### Results

perf/ServerLoadHarness `events`: 50 sessions × 15 subscriptions,
publishing 100 ms, encrypt, `DOTNET_gcServer=1`. Paired A/B against
master 4ae2395f3 (with #4845), 2 alternating rounds. Runs where
processes outside the harness used more than 1.5 cores were repeated.

| | master | this PR | change |
|---|---|---|---|
| rate 0: generated events/s | 1,805 / 2,227 | 4,127 / 3,867 | **+98 %**
|
| rate 0: server CPU µs per generated event | 1,832 / 1,787 | 1,951 /
2,011 | +9 % |
| rate 0: server KB per generated event | 112 / 110 | 91 / 92 | −18 % |
| rate 0: server cores | 3.30 / 3.97 | 8.02 / 7.76 | |
| rate 500: server CPU µs per generated event | 2,927 / 2,511 | 3,345 /
2,978 | +16 % |
| rate 500: server KB per generated event | 157 / 157 | 145 / 145 | −8 %
|
| rate 500: p50 / p99 latency ms | 68.4 / 130, 68.4 / 130 | 67.6 / 125,
67.6 / 127 | = |

- A CPU trace at rate 0 shows no saturated server thread any more.
- At rate 500, the harness's paced generator reports bursts (it sleeps
about 1–15 ms between them), so events wait and the consumer splits. The
rate-500 profiles of master and this PR have the same shape and the same
number of managed on-CPU samples. The extra process CPU (about 0.2
cores) fits kernel time for waking and parking pool threads.
- Master's own throughput varied between 1,800 and 2,800 events/s across
batches on this shared machine, so only the paired numbers are
meaningful.

### Tests

- New in `EventDeliveryOrderRegressionTests`:
- per-item order over 200 items × 50 events with split batches, also
with permission checks that complete asynchronously or late;
  - two ranges queue at the same time;
- an item whose permission check waits gets the next event only after
the previous one;
- waiting events for different items (audit events between other events)
keep every item's order and none is lost.
- `SharedEventEvaluationTests`: the mixed-filter and evaluated-once
tests also run with every event split into ranges of one item.
- Mutation-checked: dropping the join, the in-range wait or the parallel
start fails the new tests.
- Opc.Ua.Server.Tests (net10.0): 9,358 passed, 0 failed, 6 skipped.
Opc.Ua.Server and Opc.Ua.Server.Tests build for net48.

## Related Issues

- No issue. This is E2 of the event-heavy server load investigation,
following #4837 and #4845.

## 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. (Only
Opc.Ua.Server.Tests on net10.0 ran; net48 builds only.)
- [ ] 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-20 14:17:57 +00:00
Marc 4115ef696a Merge origin/master and preserve data-channel lifecycle guards
Keep data-channel close notifications while adopting terminal-error transport draining. Retain recipient-scoped event visibility alongside source-emission admission and shared filter evaluation. Reconcile overlapping NUnit 5 assertion fixes.
2026-10-20 15:29:11 +02:00
romanettandClaude Opus 5.5 bd38870cf7 perf: pump the server child's stdout on a dedicated thread (#4855)
## Summary

The 1-core client stall in the server load harness (`--client-cores 1`,
write->notify p99 0.7-1.7 s) was caused by the harness, not the OPC UA
client.

`StartServerChild` started `PumpServerOutput` with `Task.Run`. The pump
blocks in a synchronous `StreamReader.ReadLine` on the server's stdout
for the whole run, so it held one thread-pool worker permanently. With
`DOTNET_PROCESSOR_COUNT=1` the pool's minimum is one worker, so all
other work items waited for the pool's starvation injection. That is why
`DOTNET_ThreadPool_ForceMinWorkerThreads=8` and two processors both hid
the stall.

**Evidence:** 14 `dotnet-stack` samples of the constrained client during
the measurement window. In every sample, the only blocked pool worker
was in `ReadFile <- StreamReader.ReadLine <- Program.PumpServerOutput <-
StartServerChild`, and the other workers were idle. No `Opc.Ua` frame
was blocked.

**Fix:** run the pump on a dedicated background thread.

## Numbers

`run --scenario sub --sessions 25 --subs 20 --nodes 100 --pub 500
--write-interval 100 --duration 10 --warmup 6 --client-cores 1
--client-memory 512`, 5 alternating rounds (ms):

| round | base write->notify p99 | fix write->notify p99 | base write
p99 | fix write p99 |
|---|---|---|---|---|
| 1 | 659 | 61 | 619 | 12 |
| 2 | 885 | 310 | 859 | 15 |
| 3 | 306 | 303 | 52 | 23 |
| 4 | 1717 | 455 | 408 | 81 |
| 5 | 234 | 193 | 148 | 67 |

The baseline stalled (> 600 ms) in 3 of 5 runs and the fix in 0 of 5
(max 492 ms). The remaining ~300 ms p99 comes from the 500 ms publishing
interval.

## Test plan
- [x] Harness Release build (net10.0)
- [x] Alternating A/B runs above

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 14:46:35 +02:00
50062cde1b PKCS#11 store: pair a certificate without a CKA_ID only with its own key (#4826)
# Description

Follow-up to #4792. `Pkcs11CertificateStore.LoadPrivateKeyAsync` skipped
a certificate whose CKA_ID cannot be read, but a certificate with an
**empty** CKA_ID still reached `Pkcs11Token.FindPrivateKey`, which then
selects a key by the configured object filters alone and, without any
filter, returns the first private key of the token's key type. That key
may belong to another certificate, so the store could return an identity
whose signatures its certificate does not verify.

A certificate without a CKA_ID is now paired with:

- the key that carries its public key (the same lookup `AddAsync` uses
to link a certificate it writes: RSA modulus and exponent, or EC point
and curve), else
- the key that the configured `ObjectLabel` / `ObjectId` selects, but
only after that key signs a random challenge that the certificate's
public key verifies (a shared label does not establish the pairing),
else
- no key.

Before this change, a certificate without a CKA_ID on a token without
object filters got the first key of its type. A certificate with a
CKA_ID is handled as before. No public API changes. The package README
describes the pairing.

## Related Issues

Follow-up to #4792.

## 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.
(Opc.Ua.Security.Pkcs11.Tests on .NET 10, 112 passed / 10 SoftHSM
skipped; the library builds for net48, net8.0, net9.0, net10.0.)
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [x] I have addressed **all** PR feedback received.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Marc Schier <marcschier@users.noreply.github.com>
2026-10-20 12:17:22 +00:00
romanettandClaude Opus 5.5 5f6faa60d7 Server: fewer allocations per single-value Read (isolation reservations, scope reuse, routing capture without restore) (#4856)
Removes per-request allocations on the server's single-value Read path
with small, internal changes. No wire-format change, no public API
change, no change of the execution model (the receive loop is
untouched). Exactly-once semantics of the isolation leases,
cancellation, timeouts, diagnostics and auditing are unchanged.

## Effect

Allocated bytes per single-value Read (perf harness, `native-read`, 10
clients x 100 in flight, `AsyncCustomNodeManager`, Server GC;
deterministic):

| | 1 core | 4 cores |
|---|---|---|
| master 4ae2395f3 | 2929 | 2753 |
| this PR | 2320 (-21 %) | 2326 (-16 %) |

Per commit (4 cores): T1 -120 B, T2 -64 B, receive chunk list ~-70 B,
closure -17 B, pool floor -74 B, routing capture ~-100 B.

CPU per request is unchanged within the noise of a shared machine
(paired, alternating A/B rounds, 4 pinned cores: async node manager -1.8
%, encrypted -4.7 %, sync node manager +2.5 %, all ranges overlapping).
Read with 1/100 nodes and 20/200 sessions, Browse and subscriptions show
no regression and 1-14 % fewer bytes.

## Changes (one commit each)

1. **Isolation stages held as reservations.** `FairRequestQueue`
acquired three to four `Lease` objects per request. The default provider
now also admits by value through an internal
`IResourceIsolationReservations`; the queue entry keeps one compact hold
per stage and releases it exactly once with an atomic exchange. Other
providers keep using leases. `TryAcquire` and its lease are unchanged.
2. **Validation scopes reused.** Disposed `RequestValidationScope`s are
kept in per-processor spare slots and reused under a new id. Ids stay
unique, so a drain that read a scope before its reuse can only wait for
the newer validation, never miss one.
3. **Receive chunk list reused.** The server channel keeps the
`BufferCollection` of a dispatched request (released after the handler;
the decoder copied the request) to assemble the next single-chunk
message.
4. **No closure per service call.** The rejection counter lambda in the
catch block of 28 service methods made the async method allocate its
closure on every call. It moved into `CountRejectedServiceRequest`.
5. **Pool floor.** The request continuation and response source pools
retained 4 objects per processor; with many requests in flight, bursts
of completions overflowed them. They now retain at least 256.
6. **Routing snapshot captured without a restore.** #4759 captures the
routing snapshot per service call in an `AsyncLocal` through a
`ReadScope`; the capture, the scope object and the restore on dispose
cost about 190 B per Read. The dispatcher's service methods capture
until they return, and an async method's execution-context changes end
when it returns, so these sites use the new internal
`CaptureUntilReturn` (no scope, no restore, no write when already
captured). Captures that end inside a method keep the scope. The capture
itself (~90 B) remains.

Not included: pooling `FairRequestQueue.Entry` (an entry is also the
node of the lock-free FIFO and stays referenced after dispatch) and
caching a `NodeHandle` per node (the shared, publicly mutable handle
would reach overridable node manager code).

## Tests

- New: queue tests run against a reservation-capable provider and assert
every stage is released exactly once (a deliberately broken release
fails 6 of them); validation scope reuse with drains; routing capture
ends when the async method returns (sync and async completion).
- Locally on net10.0: Opc.Ua.Core.Tests, Opc.Ua.Server.Tests,
Opc.Ua.Client.Tests, Opc.Ua.Sessions.Tests and Opc.Ua.Stress.Tests
(Contract, Integration) pass; net48 test projects compile.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 12:49:27 +02:00
Marc Schier 65a67dd2f4 WoT runtime materialization, binding execution and native source emission (#4757)
# Description

- WoT registry, prepared file store, immutable content, version leases,
dependency-graph sharing (advanced graph rewrite), coordinator,
materialization, views and atomic refresh.
- Explicit caller-context propagation (`IWotRegistryService` / versioned
registry / prepared host / publication capture) and the additive
`IXRegistryProjectionContextualDelete` capability.
- UA/HTTP/MQTT binding execution and compilation, plus the 1.02 FileType
bridge and discovery.
- WoT-specific native event, source-emission and readiness integration
(`MasterNodeManager.SourceEmissions`, `EventManager` identity admission,
external-value monitored sources, Fluent attach/detach) and event lease
conservation.
- All corresponding `WotCon.*` runtime tests, WotCon sample app code,
WoT docs, CI feature budgets and Samples.Tests, plus the WotCon Design
NodeSet/csv/csproj and ModelSourceParity tests.
- Joint model metadata: XRegistry model 0.7.0 / 2026-09-12
(NamespaceVersion 63563, NamespacePublicationDate 63564), RequiredModel
date 2025-01-08.
- XML-doc work from the frozen WIP, applied to G12-owned declarations
only.

## Related Issues

- Fixes #4751.
- Part of the WoT split tracked in #4511.

## 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. (Tests included, not yet run
against current master.)
- [ ] I have added all necessary documentation.
- [ ] I have verified that my changes do not introduce (new) build or
analyzer warnings. (Not run.)
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed. (Not run.)
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [ ] I have addressed **all** PR feedback received.
2026-10-20 10:43:02 +00:00
0edc3b55a6 Server: allocate data change notifications instead of pooling them (#4830)
Stacked on #4829 (publish timer sweep): this PR targets its branch, so
the diff shows only this change. Retarget to master once #4829 merges.

MonitoredItem.Publish rented every MonitoredItemNotification from the
process-wide activator pool (BoundedObjectPool, bound 2 x monitored
items,
capped at 65536) and NotificationMessageLease returned it once the
message was sent and acknowledged. Under load the pool costs more CPU
than it saves:

- A notification lives until its message is acknowledged, so a whole
  publishing cycle of notifications is in flight at once. With the
  publish sweep finishing within a few ms (fixed cadence + parallel
  remainder) the in-flight burst is far above the cap and most Gets miss
  anyway (MonitoredItemNotification 6 % -> 45 % of server allocation).
- Raising the cap to 1M made it worse: 5.5 vs 4.4 us per notification,
  lock contention 16k vs 5k per window, delivery 93 vs 95.5 %.
- Every hit touches an instance that went cold in the ~100-200 ms since
  it was returned, Reuse touches it once more, and the shared queue and
  counters bounce between cores. A fresh allocation comes from the
  thread's hot allocation context.

The notifications are now allocated in MonitoredItem.Publish and left to
the GC; RecyclePayload no longer reuses them, and the pool bound is only
raised for EventFieldList. NotificationMessage, DataChangeNotification,
EventNotificationList and EventFieldList pooling and the rented
MonitoredItemNotification[] arrays are unchanged.

Paired A/B on top of the publish sweep fix (perf/ServerLoadHarness,
encrypted, alternating rounds, medians; runs repeated while processes
outside the harness used more than 1.5 cores):

| shape (sessions x subs x items) | GC | n | us/notif | server cores |
delivered | GC pause / 15 s | p99 write->notify |

|---------------------------------|--------|---|------------|--------------|--------------|-----------------|-------------------|
| 50 x 15 x 600, pub 100, w 150 | server | 2 | 4.4 -> 3.1 | 11.9 -> 8.2
| 89 -> 89 % | 0.8 -> 1.5 s | 278 -> 325 ms |
| 10 x 15 x 600, pub 100, w 150 | server | 3 | 5.1 -> 3.3 | 2.9 -> 2.0 |
100 -> 100 % | 15 -> 88 ms | 137 -> 141 ms |
| 1000 x 1 x 1, pub 1000, w 500 | server | 7 | 158 -> 190 | 0.16 -> 0.19
| 50 -> 50 % | 15 -> 28 ms | 480 -> 490 ms |
| 2 x 1 x 100, pub 100, w 150 | server | 3 | 59 -> 47 | 0.08 -> 0.06 |
100 -> 100 % | 7 -> 6 ms | 103 -> 114 ms |
| 50 x 15 x 600, pub 100, w 150 | ws | 3*| 4.5 -> 3.5 | 10.5 -> 7.0 | 86
-> 96 % | 5.1 -> 8.6 s | 447 -> 373 ms |
| 10 x 15 x 600, pub 100, w 150 | ws | 5*| 5.4 -> 3.6 | 3.3 -> 1.9 | 100
-> 100 % | 0.45 -> 1.1 s | 144 -> 155 ms |

(*) workstation GC rows combine the A/B with the switch-build runs of
the same comparison. Excluded: six runs (both builds) in which the
50-session shape collapsed with the server mostly idle (up to 16.7 s GC
pause in the 15 s window, 0-4 % delivered), the GC suspension stall
pattern seen before on this machine under external CPU saturation. The
1000-session cores differ by 0.03 with a same-build spread of
0.13-0.25 at ~1000 notifications/s.

Trade-off: CPU per notification drops 25-40 % wherever notifications
flow, but short-lived notifications now die young: gen0 GCs per 15 s
2 -> 24 (server GC, 10 sessions), gen1 35 -> 154 (workstation GC,
10 sessions), so GC pause time rises about 2x, and p99 latency rises
with workstation GC at moderate load (+8 % at 10 sessions). When
pooling could pay off again is tracked in #4819.



🤖 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>
2026-10-20 11:01:19 +02:00
4ae2395f37 perf: client GC measurement, V2 client by default, container-like client constraints (#4844)
Extends the out-of-process load harness (`perf/`, not part of the
product build) so it can measure client-side GC, run the V2 client by
default, and emulate a container-sized client. No product code changes.

## Client GC measurement
- New `client-gc:` result line from an in-process GC event listener
(`GcListener.cs`, runtime GC keyword at informational level): GC mode,
collections by condemned generation and trigger, pause and suspension
p50/p99/max, bytes surviving gen0/gen1, pinned objects, gen2/LOH size,
peak heap after a collection, committed bytes, heap limit, DATAS heap
count and gen0 budget. `--gc-events 0` turns it off.
- The latency recorder is now a fixed log-linear histogram (~1.6 %
resolution). The old `ConcurrentBag<long>` kept ~25 MB of samples alive
in the measured client's gen2 (client heap 29–31 MB vs 4 MB now).
- **Fix:** GC, thread-pool and processor-count settings set for the
harness (`DOTNET_GC*`, `COMPlus_GC*`, `DOTNET_ThreadPool_*`,
`DOTNET_PROCESSOR_COUNT`) no longer leak into the server child. Before,
`DOTNET_gcServer=1` switched the server too, although the README said it
switched the client. `SERVER_`-prefixed variables still configure the
server.
- TraceAnalyzer `gc` decodes the condemn reasons and breaks each
collection into phases (marking by root type with the bytes marked,
plan/relocate/compact).

## V2 client by default
- `--client` defaults to `managed` (`ManagedSession` + V2 subscription
engine). The `events` and `counters` workloads gained V2 paths;
`--pool-notifications 1` enables `WithPoolNotifications`.
`client-scenarios.txt` lists V2 scenarios. `--client classic` and
`shared` still work.

## Container-like client
- `--client-cores N`: the harness runs itself again with
`DOTNET_PROCESSOR_COUNT=N` on the last N processors and pins the server
to the others.
- `--client-memory MB`: the client's GC heap hard limit becomes 75 % of
MB, the limit .NET derives from a container memory limit (native memory
is not capped).

## What it found
The report is published separately. Highlights:
- Request path: a gen0 pause costs ~1 ms of fixed work (stack and card
scanning, compaction), so pause time follows collection count. Read-1
allocates ~2 KB/request on the client, and its p99 is the GC pause (a 64
MB gen0 halves it).
- V2 subscriptions and events never reach gen2 (classic `Subscription`
with its message cache: 22 % of wall time paused for events).
- Containers: each V2 monitored item costs 2.8–3.8 KB, mostly a per-item
`ConcurrentDictionary` in `OptionsMonitor<T>` (one lock per processor)
and a per-item `ConcurrentQueue`. 100k items do not fit in a 256 MB
container. A 1-processor client stalls ~1 s unless the thread-pool
minimum is raised.

## Testing
- Harness builds in Release (`CustomTestTarget=net10.0`). I ran every
changed scenario: read/write/browse/sub/events/counters on managed
sessions, constrained runs with 1, 2 and 4 cores and 256 MB to 1 GB, and
`TraceAnalyzer gc/alloc` on recorded traces.

🤖 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-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: marcschier <11168470+marcschier@users.noreply.github.com>
2026-10-20 09:44:26 +02:00
romanettandClaude Opus 5.5 ad8c6ce59f TCP server: close the transport only after the terminal error message was sent (#4850)
### Why
The weekly CTT regression run reported **Security Certificate Validation
`029.js`** as a regression: the OpenSecureChannel with a CA certificate
got `BadConnectionClosed` instead of `BadCertificateUseNotAllowed`. It
passed in all 10 earlier full runs.

When a channel faults (here a rejected client certificate during
OpenSecureChannel), `ForceChannelFaultCore` sends the ERR message with
`WriteMessageInline` and then closes the transport on the same stack.
The send is not awaited. If it cannot complete synchronously, `Close()`
cancels it or disposes the socket underneath it:
- it may wait for the transport's send lock, for example while the
queued writer is still finishing the Acknowledge;
- or the socket send may still be pending.

The client then sees the connection drop without the reason. OPC 10000-6
§7.1.5 requires the server to send the Error message and then close the
connection.

### Change
- `UaSCBinaryChannel.WriteMessageInline` keeps the terminal send task.
- The new `UaSCBinaryChannel.CloseTransport` closes the transport at
once when no terminal send is pending. Otherwise it closes once that
send completes, after 2 s at the latest (`TimeProvider`).
- `TcpListenerChannel.ChannelFaulted`, `ChannelClosed` and
`UaSCBinaryChannel.Dispose` use it in place of `Transport.Close()`.
- Nothing else changes: the channel still faults and is removed from the
listener synchronously; only the socket close waits.

### Tests
- New in `TcpServerChannelDeterministicTests`, both of which fail
without the fix:
- `FaultClosesTheTransportOnlyAfterTheErrorMessageWasSentAsync`: the ERR
send is held, the transport must stay open, then close after the send.
- `FaultClosesTheTransportWhenTheErrorMessageNeverCompletesAsync`: the
ERR send never completes, so the transport closes after 2 s
(FakeTimeProvider).
- `Opc.Ua.Core.Tests` Stack.Transport/Stack.Tcp (net10.0): 858/858.
- Opc.Ua.Core builds for net48.

_Includes the master build fix of #4852 (cherry-picked so CI can run);
it drops out on rebase once #4852 is merged._

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 05:17:23 +02:00
romanettandClaude Opus 5.5 6a156dfa2e Client: fewer allocations per request (shared timeout timer, pooled state machines, fewer buffer collections) (#4849)
# Description

Reduces client allocations per request. A client GC investigation found
that a gen0 pause on the request path costs about 1.1 ms however much
survives. That makes the number of collections, and therefore the bytes
allocated per request, what matters. At the default gen0 budget, each KB
removed from a request removes about one collection in eight.

Read-1 client bytes/request (`perf/ServerLoadHarness run --scenario read
--nodes 1 --sessions 20`, builds run alternately, 3–4 rounds each,
medians). Bytes/op repeated within ±5 B between rounds, while req/s
varied 3x from other load on the machine, so throughput is not reported.

| Commit | Change | classic | managed |
|---|---|---|---|
| master 484596c2a | | 1983 | – |
| 77e2ca0b5 | One timeout timer per client channel instead of an
`ITimer` per request | 1871 | – |
| 730cec198 | `BinaryDecoder` returns `Array.Empty<T>()` for zero-length
arrays (empty StringTable/DiagnosticInfos in every response header); -1
still decodes as null | 1817 | 1913 |
| 2135f7d7f | `SealSymmetricMessage` secures the encoded chunks in place
instead of copying them into a second `BufferCollection` | 1750 | 1839 |
| 70655ffa0 | A response that is a single final chunk is decoded from
the chunk directly, without saving it into a `BufferCollection` | 1675 |
1767 |
| 8edf352b9 | `ChannelAsyncOperation` is its own `IValueTaskSource` (no
`CompletionWaiter`) and locks itself (no `Lock`) | 1599 | 1686 |
| 4528d0467 | ManagedSession releases its reader lock from a pooled
state machine (.NET 6+) instead of a second task continuation | 1598 |
1598 |
| 41859b64a | `UaSCUaBinaryClientChannel.SendRequestAsync` uses
`PoolingAsyncValueTaskMethodBuilder` (.NET 6+), like `TcpByteTransport`
| 1426 | 1425 |
| b3d09802d | Review: no request admitted after Dispose;
`ChannelAsyncOperation` keeps its private `Lock` again (DeveloperGuide:
never expose locks) | 1472 | – |
| 71ec7b396 | Review: `return [];` for empty arrays | – | – |

In total: **classic 1983 → 1472 B (−26 %)**. The managed client gets the
same ~49 B back from the restored `Lock`.

### What reviewers should look at
- **Timeouts:** each operation records a deadline. The channel keeps one
timer set for the earliest pending deadline. When it fires it times out
every request whose deadline has passed and re-arms for the next. The
operation is added to `m_requests` before it schedules its deadline, and
the scan resets the scheduled time under the same lock, so no new
request is missed. With equal timeouts a new request is never the
earliest, so it only reads the scheduled time. The public
`ChannelAsyncOperation` constructors still create a timer of their own.
- **Dispose of the client channel** now fails the pending requests with
BadSecureChannelClosed. Without a timer per request they would otherwise
never complete. Requests are registered under the timeout lock and
rejected once the channel is disposed, so a request racing Dispose is
either rejected or drained.
- **Pooled value tasks:** the value tasks from `SendRequestAsync` and
from ManagedSession service calls must now be awaited once. Awaiting one
twice used to happen to work because they were backed by a task.
`WaitForCompletionAsync` was already documented as one wait at a time.
- **Not done here** (needs generator or codec API work): reusing the
BinaryEncoder/BinaryDecoder objects (~200 B), pooling the generated
`SessionClient.ReadAsync` box (~195 B), and the second `ResponseHeader`
per response.

## Related Issues

- No issue. Follow-up of the client GC investigation.

## 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.
- `PendingOperationsTimeOutAtTheirOwnDeadlinesAsync` (two deadlines on
one FakeTimeProvider)
- `RequestAfterDisposeFailsInsteadOfWaitingAsync`,
`RequestsRacingDisposeAllCompleteAsync`
  - `ReadZeroLengthArraysReturnsEmptyWithoutAllocating`
- [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.
- net10.0: Core 6450, Core.Encoders 2947, Types 13928, Client 3226
passed. One Client test (`SessionBindingLiveTests` "incarnation") timed
out once while the benchmarks were loading the machine and passed when
its fixture was rerun alone (96/0). net48: Opc.Ua.Client and its
dependencies build clean; net48 tests not run locally.
- [ ] 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-20 05:16:17 +02:00
romanettandClaude Opus 5.5 170d9460a0 plans/ctt-issues.md: C63 observed failure and CTT CI workaround (#4851)
Adds detail to the C63 entry, which only had a one-line description:
- **What was seen.** In the weekly CTT run of 2026-10-18, A & C Refresh2
`Test_002.js`, `Test_003.js` and `cleanup.js` failed with
`BadSubscriptionIdInvalid`, and the unit stopped after `Test_004.js`.
- **How often.** With a 250 ms publish interval, the stall still hits
about 1 in 5 A&C runs on the GitHub-hosted runner, sometimes inside a
unit.
- **The CTT CI workaround.** Each unit checks at its start that the
publish thread is still alive. A part whose first attempt has
regressions together with the CTT's own stall warning runs once more,
and the second attempt is the result.

No server change. Documentation only.

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 05:15:52 +02:00
romanettandClaude Opus 5.5 ac24123dde Await NUnit 5 Assert.ThrowsAsync/CatchAsync in the #4759 tests; fixes the master build (#4852)
master has failed to build since #4840 (NUnit 5) and #4759 (batch
lifecycle and continuation tests) both landed:
- #4759 calls `Assert.ThrowsAsync`/`Assert.CatchAsync` without `await`,
which was the blocking NUnit 4 form.
- Under NUnit 5 those methods return a `Task`.

This PR adds the missing `await`, the same form as the rest of the
migrated tests:
- `NodeManagerLifecycleTests.Batch.cs`: six calls (`CS4014`,
`NUnit2059`).
- `ProductionDatasetClientTests.cs`: one call (`CS4014`).
-
`HistoryReadContinuationDispatchTests.FaultedRequestKeepsAssignedClientSuppliedContinuationPoint`:
this test is synchronous, so it compiled. It discarded the returned
`Task`, which means the assertion was never checked. It is now `async
Task ...Async` and awaits it.

No assertion changes.

Tests (net10.0):
- `UA.slnx` builds.
- `NodeManagerLifecycleTests`: 317/317.
- `ProductionDatasetClientTests`: 5/5.
- `HistoryReadContinuationDispatchTests`: 6/6.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 05:15:12 +02:00
Marc b930f4f59d Fix CI asset integrity and resource counter assertions
Restore the upstream AutoID bytes trimmed during the merge and preserve their pinned hash. Assert native ownership field by field to avoid NUnit DefaultConstraint comparing padding on Linux. Wait for bounded asynchronous END/CREDIT cleanup before checking PubSub application-wide counts; stress the credit test through 200 iterations.
2026-10-19 23:47:42 +02:00
Marc a36fd6c455 Await NUnit 5 async assertions in transport tests 2026-10-19 21:47:42 +02:00
Marc 72073533df Fix native QUIC NUnit 5 diagnostics 2026-10-19 21:00:20 +02:00
Marc 8f45120608 Await NUnit 5 async assertions 2026-10-19 20:24:13 +02:00
Marc c34300540e Align native workflow with pinned .NET SDK 2026-10-19 19:39:19 +02:00
Marc ad0190c001 Merge origin/master into data-channels-quic-experimental 2026-10-19 19:27:24 +02:00
romanettandClaude Opus 5.5 2997cb8a2c SessionBindingTests: let readers confirm the live session before racing transfers (#4848)
## Problem

`BindingLivenessRacingTransfersAndCloseNeverConfirmsReplacedBindingAsync`
failed on the net48 CI runner (test-windows-net48 11/16, run
39250649914, on docs-only PR #4841) with `confirmed = 0`. The safety
check (`errors`) was empty. The reader tasks were not scheduled until
all 1000 transfers and the close had finished, so the liveness check
never saw a live binding.

## Fix (test only)
Before the first transfer, the test now waits until every reader has
confirmed the live binding at least once (bounded: 30 s, then a clear
`Assert.Fail`). The race still covers transfers and close, but the
positive assertion no longer depends on thread-pool scheduling. The
safety assertion is unchanged.

## Verification
I ran the test with a temporary `[Repeat(100)]` in Release builds:
- net10.0 and net48: 100 repeats each, no load
- net10.0 and net48: 3 × 100 repeats each with 2 busy loops per core

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 19:21:20 +02:00