Commit Graph
2833 Commits
Author SHA1 Message Date
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
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
romanettandClaude Opus 5.5 a6cc7c4bc9 Server: evaluate an event once per distinct event filter (#4845)
## Summary

The event consumer of a notifier (`MonitoredNode2`) evaluated the where
clause and read every select clause of a reported event once **per event
monitored item**, although items with an equal filter and equal session
locales get the same result. With 750 event items on the Server object
this single consumer thread capped event throughput (follow-up to #4837,
item E1 of the event load investigation).

Now each reported event is evaluated **once per distinct filter**:

- The consumer groups the items of an event by a key of their
`EventFilter` (value equality, generated structural
`IsEqual`/`GetHashCode`) and their session's `PreferredLocales`. The key
is cached per item and recreated when the item's filter or the session's
locales change (ModifyMonitoredItems, locale change). A key remembers
the equal key it was matched against, so after the first event a group
compares by reference.
- A per-event `SharedEventEvaluation` holds the where-clause result and
the field values. The first item that needs them evaluates them.
Evaluation is thread-safe, so a later partitioned fan-out (E2) can queue
a group's items on several threads without evaluating again.
- **Per item, unchanged:** duplicate check, permission and
audit-visibility checks, filtered-retain bookkeeping, queue size and
overflow events, and an own `EventFieldList` with its `ClientHandle` and
`Handle`. A trailing filtered-retain event reads its own fields through
`FilteredRetainTarget`. An item whose filter or locales changed after
the event was grouped evaluates the event itself.
- **No public API or wire change, no reflection.** The evaluation is
offered to the virtual `MonitoredItem.QueueEvent` for the duration of
the call (a `[ThreadStatic]` scope keyed by item and target). Derived
items that override `QueueEvent` therefore still see every event, and
they use the shared result only when they pass the same event on to the
base class. Detecting overrides by reflection was rejected because it is
not NativeAOT-safe.
- The shared `Variant[]` is never written after it is created.
`EventQueueHandler.Publish` only touches the `StatusResult` objects
inside it, which were already shared through the event node,
`TransformQueuedEvents` builds new lists, and pooled `EventFieldList`
reuse only reassigns its fields.

## Measurements

`perf/ServerLoadHarness`, `events` with 50 sessions × 15 subscriptions
(750 items on the Server object), publish interval 100 ms, encrypt,
server GC, `TC_QuickJitForLoops=0`/`OSR=0`. Paired A/B in 2 alternating
rounds; each run was repeated if processes outside the harness used more
than 1.5 cores. The base was #4837's tip (27b6ad0d0), whose event code
is what master has now.

| | base | this PR | change |
|---|---|---|---|
| rate 0: generated events/s | 785 (794 / 775) | 1559 (1660 / 1458) |
**+99 %** |
| rate 0: p50 / p99 event→notify ms | 66 / 166 | 64 / 159 | = |
| rate 0: KB allocated per generated event | 272 | 114 | −58 % |
| rate 0: ms server CPU per generated event | 3.08 | 2.01 | −35 % |
| rate 0: µs CPU / bytes per notification | 4.1 / 366 | 2.7 / 153 | −34
% / −58 % |
| rate 500: ms server CPU per generated event | 3.15 | 2.63 | −16 % |
| rate 500: KB allocated per generated event | 297 | 154 | −48 % |
| rate 500: p50 / p99 event→notify ms | 74.7 / 144.7 | 71.6 / 137.8 | =
(bound by the publish interval) |

CPU traces (`threads`): before, one consumer thread was 99.5 % busy,
mostly in the field read chain (`GetAttributeValue` 23.5 %,
`ReadValueAttribute` 12.5 %) plus `QueueEvent` (19 %). After, the
consumer still uses about one core, now ~87 % in per-item queueing
(`EventMonitoredItemQueue` list copies). The per-item `Variant[]`, which
was 45 % of the sampled allocated bytes, is gone. **The post-E1
single-thread cap is ~1,500–1,660 generated events/s**, the reference
for a partitioned parallel fan-out (E2).

## Tests

- New `SharedEventEvaluationTests`:
- Mixed select clauses, where clauses and session locales on one
notifier (Server node with multiple consumers and a regular node) each
get exactly what per-item evaluation gives.
- A counting `IFilterTarget` proves that 20 items with an equal filter
read the event once.
  - A derived item that overrides `QueueEvent` sees every event.
  - A session locale change takes effect with the next event.
-
`FilterRetainTests.FilteredRetainStaysPerItemWhenItemsShareTheEvaluationAsync`:
only the item that saw the condition in scope gets the trailing `Retain
= false` event.
- Test runs:
- `Opc.Ua.Server.Tests` (net10.0): all pass except
`BindingLivenessRacingTransfersAndCloseNeverConfirmsReplacedBindingAsync`,
a session-binding race that is unrelated and passes 3/3 when run alone.
After rebasing onto master, the event, condition, alarm, filter and
monitored-item subset ran 1619 of 1620 passing;
`WriteAsync_PublishesValueToMonitoredItemQueueAsync` (a data-change
write test) failed once in the parallel run and passed 9/9 alone.
- Explicit `ServerEventSubscribeLoadTestAsync`: passes over opc.tcp. The
https/opc.https fixtures fail with HTTP 503 on master too.
- Builds clean for net48.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 18:54:25 +02:00
romanettandClaude Opus 5.5 d7c2ed6caf Client: smaller per-monitored-item footprint (listener array, lazy pending-change queue, shared logger) (#4847)
# Description

Every V2 monitored item (`Opc.Ua.Client.Subscriptions`) cost about 3.8
KB of managed heap on a 24-core machine (2.8 KB on 1 core). Most of that
was per-item infrastructure whose size grows with the processor count.
With 100k items, a client limited to 256 MB (container-like) ran out of
memory during subscription setup.

- **`OptionsMonitor<T>`**: `OnChange` listeners are kept in a
`Listener[]` that is replaced as a whole on each add or remove
(`Interlocked.CompareExchange`), instead of a `ConcurrentDictionary`.
That dictionary's default concurrency level allocated one lock object
per processor for every monitored item (~1.4 KB on 24 cores).
Notification, dispose and double-dispose behave as before; listeners are
now called in registration order.
- **`MonitoredItem`**: pending changes are kept in a `Queue<Change>`
guarded by the existing `m_pendingChangesLock` instead of a per-item
`ConcurrentQueue<Change>` (~810 B, 32-slot segment). The queue is
created on enqueue and dropped once the last change completes, so idle
items hold none. `HasPendingChanges`, `TryGetPendingChange` and
`CompleteChange` now take the lock. `TryRequeue` used to enqueue without
the lock and now enqueues under it.
- **`DefaultSubscription`** shares one `ILogger` across its monitored
items instead of creating one per item.

## Measurements

These numbers come from the `ServerLoadHarness` client GC line (harness
changes are in #4844): `run --scenario sub --sessions 25 --nodes 100
--pub 500 --write-interval 100 --duration 10 --warmup 6`. All figures
are client gen2 MB.

| Config | Items | Before | After |
|---|---|---|---|
| 24 cores, `--subs 20` | 50k | 190.0 / 188.7 | 62.9 / 64.0 |
| `--client-cores 2 --client-memory 256`, `--subs 20` | 50k | 142.6 |
54.6 |
| `--client-cores 2 --client-memory 256`, `--subs 40` | 100k | out of
memory | 122.6 (limit 192) |

That saves about 2.6 KB per item on 24 cores and about 1.8 KB on 2
cores. The whole client heap is now about 1.3–1.4 KB per item.

## Related Issues

- None

## Checklist

- [x] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [x] I have added tests that prove my fix is effective or that my
feature works and increased code coverage. (`OptionsMonitorTests`:
order, removal, duplicate callbacks, concurrent add/remove)
- [x] I have added all necessary documentation.
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings. (`Opc.Ua.Client` Release build on all targets,
including net48: 0 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.Client.Tests` on net10.0 was run: 3231 passed, 0 failed, 2
skipped.)
- [ ] 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-19 18:11:11 +02:00
Marc Schier 9bfc780e14 Update NuGet dependencies and migrate tests to NUnit 5 (#4840)
# Description

Update 55 NuGet dependencies to the latest stable releases available on
2026-10-19, preserving documented compatibility pins and
framework-specific version bands. Existing OpenUSD prereleases remain
unchanged because no newer releases are available.

Migrate existing tests to NUnit 5: await asynchronous assertions,
propagate async through helpers, handle nullable predicates, and replace
obsolete `Order` attributes with sequencing dependencies. Sequencing
dependencies use `AllowFailure = true`; filtering a dependent test also
runs prerequisites. Adjust fixture-level parallelism where NUnit
dependency scheduling requires it.

Keep Roslyn API/runtime pins intact; select newer Immutable/Metadata
runtime packages only for test and profiling hosts requiring them.
Update the approved SDK pin to 10.0.402 across local, CI, and container
builds. Document NUnit 5 conventions.
2026-10-19 15:23:29 +00:00
romanettandClaude Opus 5.5 a07f4eb1ac Aggregates: Average, Count, Delta, StdDev, Variance use Good values only (Mantis 11495) (#4846)
### Why
The OPC Foundation answered [Mantis
11495](https://mantis.opcfoundation.org/view.php?id=11495) on 2026-10-12
(~0025874), as it had in
[7975](https://mantis.opcfoundation.org/view.php?id=7975) before:
- The general rules of Part 13 §4.2.1.2 apply only where an aggregate
definition says nothing else.
- Count counts only Good values; Bad and Uncertain values are never
counted, but they affect the status.

Average (§5.4.3.3), Delta (§5.4.3.27) and StandardDeviation*/Variance*
(§5.4.3.37–§5.4.3.40) also speak of Good values. With
TreatUncertainAsBad = False the server treated Uncertain values as Good
for these aggregates. That is CTT item U5 in `plans/ctt-issues.md`:
about 34 test cases in Aggregate `003-02/03`, `004-02/03`.

### Change
- `AggregateCalculator.IsGoodValue`: Good only, whatever
TreatUncertainAsBad is. It is used by Average, Count, Delta and
StandardDeviation/Variance.
- `GetValueBasedStatusCode(..., goodValuesOnly: true)` is used by
Average and Count: an Uncertain value counts as non-Good, so 2 Good + 1
Uncertain gives `UncertainDataSubNormal`, not Good.
- Unchanged:
- NumberOfTransitions: its definition compares non-Bad values; U6 is
still open.
- Time-based aggregates, bounds, and Minimum2/Maximum2 still follow
TreatUncertainAsBad as before.

### Tests
- Updated to the answer: `AverageExcludesUncertainValues` and
`DirectAndLiveCountExcludesUncertainValuesAsync`.
- New: `DirectAndLiveGoodValueAggregatesExcludeUncertainValuesAsync`,
which covers Delta, VarianceSample and StandardDeviationPopulation,
direct and live, with both TreatUncertainAsBad values.
- `ProcessedReadUsesAdvertisedNodeAggregateDefaultsAsync` now checks the
advertised TreatUncertainAsBad default through TimeAverage, because
Count no longer depends on it.
- Local runs (net10.0):
  - Opc.Ua.Server.Tests aggregates: 496/496;
  - Opc.Ua.History.Tests: 536 passed, 28 skipped;
  - client Historian tests: 118/118.
- CTT p06 (Aggregates) against this build, with the patched scripts no
longer correcting U5: 1264 pass, 0 errors, 0 warnings.

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 16:20:36 +02:00
a10f9e252d Generic runtime continuation, batch lifecycle and readiness (#4759)
# Description

This pull request extracts and integrates generic server runtime and
continuation-management capabilities for OPC UA NodeManagers.

- **NodeManager lifecycle and routing:** add batch registration and
reload, staged publication and readiness notifications, captured routing
snapshots and reservations, and safe retirement. Wire the lifecycle
through host interfaces, async/sync adapters, `MasterNodeManager`, and
`StandardServer` startup and shutdown.
- **Browse and History continuations:** retain continuation ownership
and manager dependencies across requests and reloads; add atomic claims,
session caching, and cleanup when requests fail or generations retire.
Add historian resume state, tokens, dependency capabilities, and shared
continuation-store support.
- **MonitoredItem lifecycle:** support detaching and reattaching items
across node-manager generations, including event subscriptions shared
across root notifiers and correct defaults for detached-item
modification.
- **Runtime NodeSet references:** track reference additions and
deletions through supported API paths and preserve the tracked
references during reload.
- **Published views and materialization:** add immutable `TypeTable` and
`EncodeableFactory` snapshots with publication-owner lifetime
management, and provide a live node-materialization query for the
existing WoT projection-view consumer.
- **CI trace support and documentation:** add scripts and tests for
staging NUnit internal traces, and document NodeManager lifecycle,
session continuation ownership, and historical-access behavior.

## Related Issues

- Extracts the generic runtime portion of #4511.
- #4757 depends on this pull request; this pull request does not depend
on #4757.

## 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: perf <perf@local>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-19 16:08:37 +02:00
romanettandClaude Opus 5.5 e8c5d800a6 plans/ctt-issues.md: refresh Mantis states and OPC answers, condense (#4843)
Brings `plans/ctt-issues.md` up to date with Mantis as of 2026-10-19 and
makes it shorter (427 → 347 lines). Documentation only.

### Mantis state
- Resolved issues are marked **(fixed)**: 11097, 11106, 11125/11248,
11144/11269, 11257, 11259, 11262, 11263, 11268, 11347.
- Abstracts were corrected after the Mantis discussions: C10, C11, C13,
C23, C24, C29, C46, 15, 18 and 19.
- New rows:
- C15b ([11476](https://mantis.opcfoundation.org/view.php?id=11476),
View validator);
- [11475](https://mantis.opcfoundation.org/view.php?id=11475) on item
18;
- duplicates [11466](https://mantis.opcfoundation.org/view.php?id=11466)
(C1) and [11509](https://mantis.opcfoundation.org/view.php?id=11509)
(item 14).
- **C64:** Int32 bound truncation, now filed by another reporter as
[11494](https://mantis.opcfoundation.org/view.php?id=11494) (spec clone
[11499](https://mantis.opcfoundation.org/view.php?id=11499)). It
replaces the unfiled "Int32 conversion" text.
- **C21:** the OPC Foundation decided on
[11411](https://mantis.opcfoundation.org/view.php?id=11411) to test
negated Like lists only at the start or end of a string until Part 4 is
clarified.

### OPC Foundation answers
- **U4** is rewritten around the common rule from Paul Hunkar's answers:
*the aggregate definition wins over TreatUncertainAsBad*
([11462](https://mantis.opcfoundation.org/view.php?id=11462),
[11425](https://mantis.opcfoundation.org/view.php?id=11425),
[11426](https://mantis.opcfoundation.org/view.php?id=11426),
[11427](https://mantis.opcfoundation.org/view.php?id=11427),
[11495](https://mantis.opcfoundation.org/view.php?id=11495)).
- It records that 11427 ~0025875 and 11426 ~0025852 conflict for an
Uncertain value below the minimum.
  - The 11462 follow-up (~0025849) is still unanswered.
- **U5** is answered by 11495 ~0025874 (and the earlier 7975): Count
(and by the same rule Average, Delta, StandardDeviation* and Variance*)
excludes Uncertain values also with TreatUncertainAsBad = False. **The
server currently includes them, so a server change is needed.**
- **U6** (new, open): NumberOfTransitions with TreatUncertainAsBad =
True.
- A new **Server follow-ups** section lists what the answers require of
the server: U5, a possible C48/C49 change, C38 diagnostics visibility
(11445 ~0025750), and C64 depending on 11499.

### Not filed
- Added C58–C63: CTT script defects fixed in the patched CTT scripts,
including the C60 publish-thread stall. Also added C65 (the GetBadData
forward read, related to
[11505](https://mantis.opcfoundation.org/view.php?id=11505)) and C66
(non-numeric aggregates; not filed because the CTT's values were not
recorded).
- Removed the inline draft Mantis texts for C54/C55, and condensed the
prose and the configuration notes.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 15:19:37 +02:00
romanettandClaude Opus 5.5 42bfb709c5 Channel send order: tests for concurrent producers, reconnects and the writer stop (#4838)
Test-only. Adds tests for the guarantees of the channel writer
introduced in #4782 ("seal in the writer"). The existing tests cover
them for a few producers or one message at a time. No product code
changes.

They come from roadmap item R6 of the o6 benchmark gap analysis, a
lock-free queue for the producers' responses. That queue no longer pays
off once #4805 and #4833 are in: at steady state the send path costs
about 0.5 µs per request, and removing its lock left CPU per request
unchanged. So the queue is not proposed, but its tests check the current
writer and pass on master unchanged.

## Tests

All in `UaSCBinaryChannelSendOrderTests`:

-
**`ManyProducersWithNumberedMessagesAndTokenChangesKeepTheWireOrderAsync`**
(repeated 3×):
- at least 32 producer threads (twice the processor count) send
responses of random size, some split into chunks;
- meanwhile, one producer queues messages numbered under `SendOrder`, as
OpenSecureChannel responses and faults are, and another activates 15 new
tokens.
- Checked: consecutive sequence numbers on the wire, contiguous chunks,
every response exactly once, and no token used after its successor.
- **`ResponsesQueuedWhileTheWriterStopsAreSentAsync`**: one producer
queues each response the moment the previous one reaches the wire, which
is when the writer finds its queue empty and stops. It runs 20,000
times, and every response must be sent.
- The producer waits with `Task.Yield`, not by spinning. The writer is
queued to the producer's local work queue, and a spinning producer would
hold it.
- It uses one producer on purpose: with several, the next producer's
response restarts the writer and carries a stranded one along, which
hides the bug.
- **`AReconnectWhileProducersQueueSendsTheOpenResponseFirstAsync`**
(repeated 3×): eight producers keep sending while the transport is
replaced. The new transport is attached and the OpenSecureChannel
response numbered under one hold of `SendOrder`, as a reconnect does.
Checked:
- the OpenSecureChannel response is the first message on the new
transport, and the transport numbers on without a gap;
  - nothing is sent on both transports;
  - every response queued after the reconnect arrives;
  - every buffer is returned.
- **`ASealFailureInTheMiddleOfABatchStopsTheNumberingAsync`**: six
responses wait in one batch behind a held send. The first fails to
secure after it took its sequence numbers. Checked:
- no message after it is sent, including one queued after the encryption
works again;
  - the channel reports one transport error;
  - every buffer is returned.

Test helpers:
- `OrderServerChannel`: `ReconnectForTest`, `ActivateTokenForTest`;
- `RecordingTransport`: `SendCount`, `SentChunks()`;
- the test class: `AssertContiguous`, for a transport that only carries
part of the messages.

## Mutations

Each test was run against a deliberately broken channel. Each break was
temporary and is not in this PR:

| Change to `UaSCUaBinaryChannel` | Fails |
|---|---|
| A producer reads the writer's running flag before it takes the write
queue lock, so a writer that stops in between strands the response |
`ResponsesQueuedWhileTheWriterStopsAreSentAsync` (3 of 3 runs) |
| The writer takes unsealed responses ahead of producer-numbered writes
|
`ManyProducersWithNumberedMessagesAndTokenChangesKeepTheWireOrderAsync`,
`RandomReceiveSendAndRenewalInterleavingKeepsTheWireOrderAsync` |
| The writer numbers again after a seal failure left a gap |
`ASealFailureInTheMiddleOfABatchStopsTheNumberingAsync`,
`ASealFailureAfterNumberingStopsTheChannelAsync` |

## Testing
- Local, net10.0: `UaSCBinaryChannelSendOrderTests` and
`TcpReconnectKeyAgreementTests`, 61 passed.
- net48 builds without warnings. `dotnet format whitespace
--verify-no-changes` passes on the changed file.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 10:59:07 +00:00
romanettandClaude Opus 5.5 33d6d29e12 Server: wake fair queue workers through a LIFO signal, let a looking worker take new requests (#4833)
Request queue workers of the `FairRequestQueue` waited on a bounded
`Channel<bool>(1)` for the coalesced wake token. With many cores, its
monitor became the server's largest lock contention point, and each wake
cost a channel operation, an allocated wait and a thread-pool hand-off.
This PR replaces that signal and lets a worker that just finished a
request keep looking for the next one for a few microseconds before it
parks.

The concept is unchanged:
- requests are admitted into the fair queue;
- `MinRequestThreadCount`/`MaxRequestThreadCount` workers dequeue and
run them;
- the receive loop is not touched;
- weighted fairness, the per-owner and global limits, leases, admission
and `BadServerTooBusy`, cancellation with an exactly-once lease release,
stop and drain, and parked publish requests behave as before.

There are no wire, public API or configuration changes.

## Results

Paired A/B against `master` fc0687a2d (with #4787, #4788, #4805, #4812),
5 rounds per scenario. The builds alternate per round, starting side
swapped every round. A run during which another test host or load
harness ran was discarded and repeated. Medians, ranges in brackets.

The o6read rows use the o6-automation benchmark's native open62541
client workers against the server configured like the o6 2.0.1 worker:
- `AsyncCustomNodeManager`;
- Server GC;
- queue sized clients × in flight + 3;
- 200 workers unless noted.

They run at steady state: 100,000 warm-up calls per client, then 300,000
measured. See the note on the default run below.

Machine: Ryzen AI 9 HX 370 (4 Zen 5 cores = logical 0–7, 8 Zen 5c
cores), 24 logical processors, Windows 11.

| Scenario | req/s master → this PR | Server CPU µs/req | Contention
events/s |
|---|---|---|---|
| o6read 10×100, 24 cores | 567.8k → **688.6k (+21 %)** | 28.1
[25.6–30.1] → **23.1 [22.3–25.8] (−18 %)** | 883 → 183 |
| o6read 10×1000, 24 cores | 536.2k → **656.0k (+22 %)** | 28.8 → **21.7
(−25 %)** | 264 → 34 |
| o6read 10×100 encrypted, 24 cores | 450.5k → **508.1k (+13 %)** | 32.2
→ **26.0 (−19 %)** | 273 → 68 |
| o6read 10×100, 8 Zen 5 threads (`0xFF`) ¹ | 423.3k → **731.8k (+73
%)** | 18.4 [15.9–18.9] → **10.4 [10.2–10.8] (−43 %)** | 301 → 234 |
| o6read 10×100, 4 Zen 5 threads (`0xF`) ¹ | 394.6k → 427.9k (rounds
overlap) | 9.0 [8.7–13.8] → 8.7 [8.5–10.8] | 68 → 58 |
| same, encrypted ¹ | 375.6k → 400.2k (rounds overlap) | 9.6 [9.4–10.4]
→ 9.4 [9.3–10.9] | 15 → 7 |
| o6read 10×100, `MaxRequestThreadCount` 16 | 593.8k → 705.1k | 26.8 →
22.8 (−15 %) | 693 → 188 |
| same, 64 | 576.6k → 695.1k | 27.9 → 23.5 (−16 %) | 729 → 161 |
| same, 200 | 585.1k → 692.5k | 27.2 → 22.9 (−16 %) | 812 → 186 |
| o6read 10×100, default short run (100k calls) | 312.3k → 335.8k | 60.5
→ 56.7 | 739 → 160 |
| `read --nodes 1`, 20 sessions | 233.0k → 230.9k | 45.4 → 45.0 | 267 →
9 |
| `read --nodes 1`, 200 sessions | 233.5k → 237.2k | 38.9 → 39.5 | 222 →
78 |
| `read --nodes 1`, `--client shared` | 97.7k → 98.0k | 33.9 → **30.4
(−10 %)** | 13 → 6 |
| `browse` | 41.3k → 41.8k | 153.3 → 153.0 | 24,139 → 24,618 |
| `sub` (notifications/s) | 88.3k → 88.0k | 7.6 → 8.0 (noise: 6.2–8.9 /
7.3–8.9) | 1 → 1 |

Latency (`read`, `browse`, `sub`): p50 and p99 unchanged or lower.
Examples:
- `read`, 20 sessions: p99 0.76 → 0.70 ms;
- `read`, 200 sessions: p99 4.70 → 4.55 ms;
- `read`, shared client: p99 0.98 → 0.93 ms.

Allocated bytes per request are unchanged (±1 %).

**What the numbers show.**
- With many cores the wake path was the cost. The configured worker
count no longer matters: 16, 64 and 200 workers cost the same.
- The largest gain is with 8 logical processors. There every hand-off to
a parked worker took one of few cores away from the receive loops.
- At 4 logical processors this PR is neutral. The first version spun
there too and cost about 13 % req/s (measured by the o6 gap analysis:
8.8 → 10.0 µs per request); readers now spin only with at least 8
processors.

¹ Rows marked ¹ are from a later paired run (5 rounds, same method) of
the current head against `master` fc0687a2d. The other rows are from the
head before the last commit, which changed nothing above 8 processors.

**Traces** (o6read 10×100 at 24 cores, same window, one run per side):
- Contention events: 4,313 → 786. On master, 56 % were the wake
channel's monitor (`DequeueAsync` → `BoundedChannel` lock, plus
`Pulse`). After this PR the fair queue accounts for about 1.5 %.
- On-CPU samples: `Monitor.Enter_Slowpath` 2.9 % → 0.2 %, and the
`DequeueAsync` state machine 2.7 % → 0.3 %.
- `TrySpinDequeue` is 9 % of the samples. That is the time workers spend
looking instead of being woken through the pool.

**The default o6read run measures JIT warm-up.** The o6 benchmark's
default parameters (10,000 warm-up and 100,000 measured calls per
client, about 3.5 s) put tier-0 code into the measurement at 24 cores.
The same server runs at about 60 µs per request in the short run and
about 28 µs at steady state; with `DOTNET_TieredCompilation=0` the short
run gives about 30 µs. The steady-state rows above are the ones that
compare the queue. The "24-core cost doubles" observation in the o6 gap
report comes mostly from this warm-up, not from the hand-off.

## Changes

### `WorkerWakeSignal` (new, internal)
The coalesced wake token, without a channel:
- At most one token is outstanding. `TryIssue` creates it with one
compare-and-swap. It hands the token to the most recently parked worker
(a LIFO stack under a small lock), or leaves it for the next worker,
which then does not park.
- While a woken worker has not run yet, further issues are coalesced, as
before. The token is consumed when the woken worker resumes, and that
worker looks at the queue afterwards, so no change is missed.
- Each worker reuses one waiter (`IValueTaskSource`), so parking
allocates nothing.
- A woken or cancelled waiter resumes on the thread pool
(`IThreadPoolWorkItem`, global queue), never on the thread that issued
the token, so a request is never executed on the receive loop.
- A cancelled waiter leaves the stack. A token handed to a waiter whose
cancellation raced the wake is passed on. `Close` ends every parked
wait.

### `FairRequestQueue`
- **Spin before parking.** `DequeueAsync` looks once more for up to 5 µs
before parking (`SpinWait`, which yields and reaches GC safe points). At
most `ProcessorCount / 8` workers spin at once; with fewer than 8
processors none do, because a spinning worker takes a processor the
receive loops need.
- While a worker is looking, `Pulse` wakes nobody: the looking worker
sees the change through the queue's version.
- A spinning worker leaves the count before its last look, and a change
is published before its `Pulse`. So either the worker sees the change,
or the `Pulse` sees nobody looking and wakes a parked worker.
- A worker that takes a request while spinning pulses afterwards for the
rest of the backlog.
- **Adaptive spinning.** A spin that finds work raises a score; one that
runs out lowers it. At zero, only one parking worker in 16 probes with a
spin. Workers therefore keep spinning while at least about a third of
the spins find work, and moderate loads such as `browse` do not pay for
spins that find nothing.
- With 20 µs spins and no score, `browse` cost +6 % CPU per request.
With 5 µs and the score it costs the same as master.
- **Hot counters on separate cache lines.** The request count,
ready-token count, version, sequence, spinner count and wake statistics
are each on a cache line of their own (`HotCounters`), away from the
queue's read-mostly fields. `IsStopped` uses a full fence and a read
instead of an interlocked operation on the flag, which took the flag's
cache line exclusively on every admission and dispatch. In an
intermediate measurement this was worth 25.4 vs 26.9 µs per request at
24 cores.
- `DequeueAsync(waiter, ct)`: the worker overload. `DequeueAsync(ct)`
remains for callers without a waiter.

### `ServerBase.RequestQueue`
Each worker creates its waiter once and parks without registering on the
shared stop token. Stopping disposes the fair queue, which ends every
parked wait, as the channel completion did before.

## Measured and not kept
- **Spin duration and spinner count:** 2, 5, 20, 50 and 200 µs, with 1,
3, 6 or 12 spinners.
- The o6read gain is the same from 5 to 50 µs; 2 µs loses most of it at
8 processors.
- With 1 spinner the gain is smaller at 24 cores; with 6 or 12, CPU
rises.
- At 4 processors, one spinner cost about 13 % req/s at steady state; no
spinner is neutral against master.
- **Spinning without the channel replacement, and the channel
replacement without spinning:** each gave little alone (o6read 24 cores:
29.7 µs with spinning off vs 26.6 µs with it).
- **No new configuration knob:** the defaults did as well as any setting
tried.

## Testing
- New `WorkerWakeSignalTests`:
  - a token issued without a waiter;
  - coalescing until the token is consumed;
  - LIFO wake order;
  - continuation off the issuing thread;
  - a cancelled waiter leaving the stack;
  - cancellation racing a wake never losing the token (500 rounds);
  - `Close`;
- no lost wake with reused waiters (20,000 items, 3 producers, 6
consumers).
- New and extended `FairRequestQueueTests`:
- the lost-wakeup stress with spinning off, default and forced (1, 2 or
8 spinners, 50 µs), weighted and FIFO, with reused waiters;
  - a spinning reader takes a request without waking a parked one;
  - spins that run out stop spinning without losing wakes;
- a parked reader cancelled while a request arrives strands nothing, and
the lease is released exactly once (300 rounds);
- the configured worker count bounds concurrent execution (1 and 3
workers);
  - stop while workers spin or park.
- Local, net10.0:
  - Opc.Ua.Core.Tests: 6,555 passed, 88 skipped;
  - Opc.Ua.Server.Tests: 8,945 passed;
  - Opc.Ua.Sessions.Tests: 858 passed, 379 skipped;
- Opc.Ua.Redundancy.Server.Tests and .Client.Tests: 884 and 134 passed;
  - Opc.Ua.Stress.Tests Contract + Integration: 45 passed.

These suites ran on the branch before the rebase onto #4812. After the
rebase: Opc.Ua.Core.Tests Stack.Server 1,930 passed, 80 skipped;
Opc.Ua.Server.Tests ResourceIsolation and RequestQueue 185 passed.
- net48 builds; the new code uses `SpinWait` without `Sleep(1)` there.
- The Explicit `LoadTest` fixture fails 12 tests on master too (HTTPS
`BadServerTooBusy`, subscribe receive ratio, ArrayPool diagnostics). On
the branch, one opc.tcp event subscription run also failed (drain cap
reached at 0.91). Reruns: branch 3/3 passed, master 2/3 (0.61 in the
failing run).

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 12:31:47 +02:00
0f85a9f719 AutoID: the OPC UA for AutoID Devices companion SDK (OPC 30010) (#4825)
# Description

This completes the AutoID contribution proposed in #4824: the model
package for [OPC 30010 OPC UA for AutoID
Devices](https://reference.opcfoundation.org/AutoID/v101/docs/) (1.01)
plus the server, the client, an MCP profile and a sample pair, in one
PR. It follows the newest companion pattern (GMS #4765, MachineTool,
CranesHoists, MachineVision).

## Commits

1. `fix(sourcegen)`: a structure property whose DataType a subtype
refines is redeclared with its own field (the NodeSet needs it;
unchanged from the first slice).
2. `feat(autoid)` model package `Opc.Ua.AutoID` (unchanged from the
first slice). Adds only `AutoIdReaderKind`.
3. `feat(autoid)` server package `Opc.Ua.AutoID.Server`.
4. `feat(autoid)` client package `Opc.Ua.AutoID.Client`.
5. `feat(autoid)` MCP profile `Opc.Ua.Mcp.AutoID`.
6. `feat(autoid)` samples `samples/AutoID`.
7. `build(autoid)` solution, package list and package validation tests.
8. `fix(autoid)` x3, `docs(autoid)` and `test(autoid)`: review fixes,
net48 compatibility, documentation, the native AOT test.
9. `fix(autoid)` x3 and `feat(autoid-client)`: `GetLocation` location
types, the `Client` of access events, the default scan cycle of the
simulated readers, and NodeIds and methods in the device snapshot
(details below).
10. `feat(autoid)`, `test(autoid)`, `docs(autoid)` (`afdd7858b`,
`22bc3b26e`, `ed25fe4ae`, on top of the master merge `1c7948f65`):
AutoID composes into an existing Device Integration owner like Pumps and
Scales (review feedback; see "Composition with other DI-based
companions").
11. `1458af9e9` (marcschier): the `AutoIdReadOptions` remarks state that
`StatusOnly` limits the values read and that each call still browses
(Copilot review).
12. `fix(autoid)` `3ef2f1507`: `GetDeviceLocation` answers with the same
status codes as `GetLocation`; `feat(autoid-mcp)` `f6405152f`:
`autoid_read_device` shows the new snapshot NodeIds and methods (details
below).

## Server: `Opc.Ua.AutoID.Server`

- Reader devices (RFID, OCR, optical, optical verifier, RTLS) on the DI
device model, created by a fluent builder (`AutoIdRuntime.Reader(name,
provider)`) and hosted with `AddAutoId` / `ConfigureAutoId` on
`IServiceCollection`.
- Hardware is behind provider interfaces: `IAutoIdReaderProvider`,
`IAutoIdScanProvider` (Scan), `IAutoIdAsyncScanProvider`
(ScanStart/ScanStop), `IAutoIdRfidProvider` (ReadTag, WriteTag,
WriteTagID, KillTag, LockTag, SetTagPassword, each opt-in by flag),
`IAutoIdRtlsProvider`. Providers push scans, presence, log entries and
access events through `IAutoIdReaderSink`. In-memory providers (an EPC
Gen2 tag model with banks, passwords, locks and kill) are included for
tests and samples.
- Optional children (methods, `LastScanData`, `DeviceLocation`,
`AntennaNames`, location methods) exist only when the provider supports
them. Facet profiles and conformance units are advertised only for what
is implemented (`IConformanceContributor`); the Full facet is withheld.
- `GetLocation` (OPC 30010 6.6.3.2) returns the location in the
requested `LocationType`. An unsupported type is `Bad_InvalidArgument`,
an unknown object `Bad_NotFound`, and a supported type the object's
location is not available in `Bad_InvalidState` (6.6.3.2: "not available
or not allowed e.g. due to special configuration"); a provider result of
another type never reaches the client. `InMemoryRtlsProvider` takes an
optional location converter, for example from the configured local
coordinate system (9.3.4) into WGS84.
- `GetDeviceLocation` (6.1.3.7) uses the same mapping: an undefined
location type is `Bad_InvalidArgument`, a device location that is not
available in the requested type `Bad_InvalidState` (it answered
`Bad_NotSupported` before). 6.1.3.7 lists only "Standard OPC UA status
codes defined for the Call Service in OPC 10000-4"; there
`Bad_NotSupported` means "The Method is not supported for the Object
instance", which is wrong for a published method, and OPC 10000-4
5.12.2.4 allows reusing an existing status code with the same canonical
meaning, so the method follows 6.6.3.2.
- Access events (7.10, 7.11): `Client` is "the name of the client
application which was the originator of the command", so it is the
application name of the session's client description (else its
application URI), not the user identity. The results of a `ScanStart`
scan name the client that started it; scans the device reports on its
own name the device.
- The simulated readers default to one scan cycle per second
(`CycleInterval`, configurable per provider; the length of a cycle is
device specific in OPC 30010), so an unbounded `ScanStart` raises at
most one scan event per second.
- Behaviour: one scan at a time with a generation counter;
ScanStart/ScanStop serialised; a scan started by a session stops when
that session closes; the maximum scan duration, tag read length and
result count are limited by `AutoIdServerOptions`; device status changes
and events are raised outside locks; registration is rolled back on
failure; readers, providers and the runtime are disposed in order and
in-flight operations are drained.

## Composition with other DI-based companions

A server has one Device Integration owner, and AutoID now uses the
existing harmonized composition instead of requiring its own node
manager:

- `AutoIdMachineryModelProvider` adds the AutoID model to the Machinery
owner's model-provider pipeline (dependency-ordered, `Order` 200) and
keeps the DI nodes the owner loaded, so DI and `DeviceSet` exist once.
- `AddAutoIdToMachinery` (after `AddMachinery`) registers it;
`ConfigureAutoIdFor<TNodeManager>` creates readers on any
`DiNodeManager` owner, successive delegates share one `AutoIdRuntime`;
`owner.CreateAutoIdRuntime()` is the path without DI hosting. The owner
disposes the runtime on shutdown.
- Readers are registered in the owner below its `DeviceSet`, NodeIds in
the owner's instance namespace (`AutoIdRuntime.InstanceNamespaceIndex`),
not in the `Machines` folder; the AutoID conformance units and facets
are published by the owner next to the DI and Machinery ones.
- `AddAutoId` remains for the "AutoID is the only DI-based companion"
case and is built from the same provider. Next to another DI owner it is
still rejected; the message names the composition API. (Bypassing that
check gives two `DeviceSet` copies; the server routes `DeviceSet` to the
first owner and readers in the second are not browsable, checked
locally.)
- `Opc.Ua.AutoID.Server` now references `Opc.Ua.Machinery.Server` (a
stable package), as Vision does; a stand-alone server still loads only
DI and AutoID.

## Client: `Opc.Ua.AutoID.Client`

Reader discovery (a failed browse propagates), typed calls for every
published method, a device snapshot with documented `AutoIdReadOptions`,
and an async event stream (`ObserveScan/Access/Presence/LogEntries`) on
`IStreamingSubscription`. Cancellation is honoured throughout.
`AddAutoIdClient` registers it for DI. Because most children of a device
are optional (6.1, 6.5.3, 6.6.3), the snapshot also carries the NodeIds
of `DeviceStatus`, `LastScanData`, `LastScanTimestamp` and
`DeviceLocation` and the browse names of the published methods
(`Methods`, `HasMethod`), taken from the same browse whatever the read
options, so a client can monitor the device and knows what it may call
without browsing again.

## MCP profile: `Opc.Ua.Mcp.AutoID`

`McpToolProfile.AutoId` with nine tools: list readers, read device,
scan, scan start/stop, observe scan events, read tag, get supported
location types, get location. `autoid_read_device` also returns
`deviceStatusId`, `lastScanDataId`, `lastScanTimestampId` and
`deviceLocationId` (NodeId strings, null when the device does not
publish the variable) and `methods` (the browse names of the published
methods), built explicitly without reflection. Registered in the host
builder, the profile switches of the other profiles,
`Test-McpGrade.ps1`, `McpREADME.md` and `.mcp/server.json`.

## Samples

`samples/AutoID/AutoIdServer` (a dock-door reader with a simulated
pallet) and `AutoIdClient` with a README and
`Test-AutoIdSamplePair.ps1`. The default port is not a common one; tests
use port 0.

## Other changes

- `Opc.Ua.Di.Server` gets `InternalsVisibleTo` for
`Opc.Ua.AutoID.Server` (`RegisterRootNotifierAsync`).
- `UA.slnx`, `.azurepipelines/expected-packages.txt`,
`PackageVersionPolicyTests`, `NuGetPackageSetValidationTests`.
- Docs: `docs/AutoID.md`, `docs/McpServer.md`, `docs/Profiles.md`,
`docs/Samples.md`, `docs/DeveloperGuide.md`, `docs/README.md`,
`docs/WhatsNewIn2.0.md`, `README.md`, NugetREADMEs. The "model package
only" statements are removed.
- All packages follow the root version (no preview override).

## Specification notes and questions for the maintainers

- The model is 1.01, published 2020-06-18 (the NodeSet header). OPC
30010 6.1.3.16 prescribes the `AutoIdModelVersion` string `"1.00"`,
which is what the NodeSet carries and what the server publishes.
- **Question:** the NodeSet's optical verifier `Scan` method returns
`WGS84Coordinate`, which looks like a model error. It is not published,
and providers that implement `IAutoIdScanProvider` for a verifier are
rejected. Is that the behaviour you want?
- The stack answers a call to an unpublished optional method with
`BadNotImplemented`.

## Design choices reviewers may want changed

- `Opc.Ua.Di.Server` gets `InternalsVisibleTo` for
`Opc.Ua.AutoID.Server`, so the server can call
`RegisterRootNotifierAsync`.
- `AutoIdReaderKind` (and `AutoIdReaderKinds.TypeIdOf`) lives in the
model package `Opc.Ua.AutoID`, not in the server package, so the client
and the MCP tools share it.
- The MCP profile is read-only/safe: no tools that write, kill or lock
tags.

## Tests

`tests/Opc.Ua.AutoID.Tests` has 306 tests (116 of the model slice, 190
new): reader lifecycle, scan semantics, tag access handlers, limits,
conformance, in-memory providers, hosting, and over-the-wire
client/server tests with result codes, events, concurrency and dispose.
`tests/Opc.Ua.Tools.Tests` has 13 AutoID MCP tests plus the updated
profile-count tests. New behaviour tests fail without the code (for
example a provider's own cancellation, a synchronous scan while a scan
stop is in progress; for the last round: `GetLocation` of a supported
type the object is not located in answered `Bad_NotFound`, access events
named the user "Anonymous" and attributed `ScanStart` results to the
device, and an unbounded `ScanStart` on a default simulated reader
reported 20 times in 400 ms). Composition: `AutoIdCompositionTests`
(stock Machinery owner, providers out of order, DI loaded once, instance
ownership, rollback, cancellation, runtime disposal, an
application-owned DI owner, a second DI owner rejected) and
`AutoIdMachineryCompositionEndToEndTests` (Machinery + Pumps + AutoID in
one hosted server, read back by the AutoID, Machinery and Pumps clients,
including a scan and the published profiles and conformance units).
Mutating the change (no lifetime registration, the DI instance namespace
for browse names, the old ownership message) fails three of them. The
sample pair script passes. `tests/Opc.Ua.Aot.Tests.Mcp` has an AutoID
profile test (`AutoIdMcpAotTests`); the project was published with
Native AOT on osx-arm64 and all 64 tests passed. Publishing it also
found two `JsonArray.Add<T>` uses in the MCP tools (IL2026/IL3050), now
fixed.

## Verification (local, macOS arm64, SDK pinned in `global.json`)

The branch contains the master merges `fc556be7e`, `1c7948f65` and
`af7feb886`; the three composition commits sit between the last two (the
latest merge touches no AutoID, DI or Machinery file). `1458af9e9`
(marcschier) and the two commits of the last round follow `af7feb886`.

- **Last round (`3ef2f1507`, `f6405152f`).** Builds with `-m:2
-nodeReuse:false -p:UseSharedCompilation=false`: `Opc.Ua.AutoID.Tests`
on all target frameworks and with `-p:CustomTestTarget=net48`,
`Opc.Ua.Mcp.AutoID` on net8.0/net9.0/net10.0, `Opc.Ua.Tools.Tests` on
net10.0: 0 warnings, 0 errors. `Opc.Ua.AutoID.Tests` 306/306 on net10.0;
`Opc.Ua.Tools.Tests` AutoID tests 60/60, and the profile, catalogue,
composition and package tests that reference AutoID (2241) pass. The
changed tests fail without the code: `GetDeviceLocation` answered
`Bad_NotSupported` for both cases, and `autoid_read_device` had no
`deviceStatusId`. `tests/Opc.Ua.Aot.Tests.Mcp` published with Native AOT
on osx-arm64 (no IL2026/IL3050 warning): 64/64 passed. `dotnet format`
whitespace, style and analyzers `--verify-no-changes` on the changed
files: no finding on a changed line (line endings aside, which the LF
checkout on macOS reports for every file).

- **Build (composition round).** `Opc.Ua.AutoID.Server` on all target
frameworks, `Opc.Ua.AutoID.Tests` on net10.0 and with
`-p:CustomTestTarget=net48`, and the AutoID server sample: 0 warnings, 0
errors. MCP tools and JSON unchanged, so the native AOT test was not
republished.
- **Build (earlier rounds).** The AutoID packages and
`Opc.Ua.AutoID.Tests` on net10.0 and, with `-p:CustomTestTarget=net48`,
on net48/net8.0/net9.0/net10.0; `Opc.Ua.Tools.Tests` and
`Opc.Ua.SourceGeneration.Core.Tests` on net10.0 (`-m:2 -nodeReuse:false
-p:UseSharedCompilation=false`): 0 warnings, 0 errors.
- **Tests.** `Opc.Ua.AutoID.Tests` 306/306 on net10.0 (composition
round; earlier round 288/288); `Opc.Ua.Tools.Tests` (AutoID, host
builder, profile and package tests) 2438/2438 on net10.0;
`Opc.Ua.SourceGeneration.Core.Tests` 4358 passed, 8 skipped, after the
master merge touched `NodeStateGenerator.cs`. net48 was built, not run
(no Mono on macOS); the fork CI runs it. The MCP tools and their JSON
are unchanged in the last round, so the native AOT test was not
republished.
- **Coverage.** Composition round: the local coverlet collector recorded
zero hits for every module in this environment (also for unchanged
code), so no new local number; the new paths are exercised by the 18
composition tests listed above and the fork CI uploads coverage. Earlier
round: cobertura from the AutoID tests: `Opc.Ua.AutoID.Server` 92.0 %
lines / 86.8 % branches, `Opc.Ua.AutoID.Client` 89.9 % / 80 %; 110 of
the 112 coverable changed lines of the last round are covered (the two
missed lines skip a browse result without NodeId or name). The generated
code of the model package is excluded as in the other model packages.
- **Format.** `dotnet format` whitespace, style and analyzers
(`--severity info`) `--verify-no-changes` on the changed files: clean.

Fork CI (full matrix including macOS): [run
39162077465](https://github.com/biancode/UA-.NETStandard/actions/runs/39162077465)
on `f6405152f`: green. (Previous round: run 39130154771 on `ed25fe4ae`,
90/90 jobs green; the upstream checks on the merge `af7feb886` green,
108 passed, 5 skipped.)
CodeQL: [run
39162079826](https://github.com/biancode/UA-.NETStandard/actions/runs/39162079826)
on `f6405152f`: green, no alert in a file this PR touches. (Previous
round: run 39130156598 on `ed25fe4ae`, green.)

## Related Issues

- Part of #4824 (tracking issue: AutoID companion SDK).

## Checklist

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

---------

Co-authored-by: Marc Schier <marcschier@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-10-19 12:30:33 +02:00
romanettandClaude Opus 5.5 484596c2a8 Server: cheaper per-item event delivery (O(1) dequeue, duplicate index, filter context reuse, no per-read timestamps) (#4837)
# Description

Low-risk speedups in the server event delivery path. With many event
monitored items on one notifier (750 on the Server object in the
`events` harness scenario), a single `MonitoredNode2` consumer thread
delivers every event to every item and is 100 % busy, which caps the
server at ~770 events/s. This PR makes the per-item work cheaper. There
are no public API or wire changes. Each commit stands alone:

1. **Server: dequeue events in O(1).** `EventMonitoredItemQueue.Dequeue`
used `List.RemoveAt(0)`, which shifted every queued event. It now
advances a head index, compacts the list once the dequeued slots are
half of it, and clears it when it runs empty. Discard oldest/newest, the
`SetQueueSize` truncation and the newest-1000 duplicate range are
unchanged. The mirroring redundancy queue snapshots only the queued
range.
2. **Server: reuse the event filter context of a monitored item.**
`QueueEvent` created a `FilterContext` per event and item. The item now
keeps it until the session replaces its `PreferredLocales`.
`GetEventFields` writes into a `Variant[]` instead of a `List` that was
then copied, and translates with the context's locales instead of
looking up the session per LocalizedText field.
3. **Server: check event duplicates against an index of the newest
entries.** `IsEventContainedInQueue` loaded the handle of up to 1000
queued entries per item and event. A reference-keyed count of the event
instances in the newest 1000 entries, kept current by
`Enqueue`/`Dequeue`/`SetQueueSize`, makes it one lookup. The checked
range is the same, and wrapped filtered-retain handles count as their
instance. If a derived queue changes `m_events` directly, the index is
rebuilt.
4. **Core: evaluate an empty content filter without an evaluator.**
`ContentFilter.Evaluate` created a `FilterEvaluator` and, through the
telemetry context, a new logger for every evaluation, also for the empty
where clause of most event filters (~16 % of the scenario's allocated
bytes). An empty filter now passes directly, and the evaluator creates
its logger only to log a failure.
5. **Core: give the fields of an initialized event a source timestamp.**
`BaseEventState.Initialize` created its field variables without a
timestamp, so `BaseVariableState.ReadValueAttribute` called
`DateTimeUtc.Now` on every read of every field for every monitored item.
The fields now carry the event time (`ReceiveTime` its own value). The
read path is unchanged, so no `ReadChildAttribute`/`ReadValueAttribute`
override is bypassed.

## Measurements

`perf/ServerLoadHarness run --scenario events --sessions 50 --subs 15
--pub 100 --security encrypt --event-rate 0 --duration 15 --warmup 8`,
with `DOTNET_gcServer=1`, `SERVER_DOTNET_gcServer=1` and
`SERVER_DOTNET_TC_QuickJitForLoops=0`,
`SERVER_DOTNET_TC_OnStackReplacement=0`. Each build is stacked on the
previous one; values are the mean of 2 alternating rounds. Runs where
processes outside the harness used more than 1.5 cores were repeated.

| build | generated events/s | event→notify p50 | KB / generated event |
ms CPU / generated event |
|---|---|---|---|---|
| master | 768 | 69.8 ms | 567 | 2.97 |
| + O(1) dequeue | 791 | 68.4 ms | 566 | 2.78 |
| + filter context reuse | 818 | 68.8 ms | 354 | 2.75 |
| + duplicate index | 854 | 63.7 ms | 356 | 2.96 |
| + empty where clause | 891 | 63.7 ms | 273 | 2.77 |
| + event field timestamps | **1001** | **61.3 ms** | **272** | **2.66**
|

- **`--event-rate 500` (below capacity):** 3.34 → 3.03 ms CPU and 590 →
294 KB per generated event. Latency is unchanged (bound by the
publishing interval).
- **Default JIT, 40 s warm-up:** 804 → 1063 events/s, 568 → 268 KB, 2.69
→ 2.37 ms.
- **CPU traces:** the consumer thread's `ReadValueAttribute` self share
drops from 24 % to 6 % of the samples and `QueueEvent` self from 14 % to
5 %. The thread is still the bottleneck, now in the generic read chain
(`GetAttributeValue` → `ReadChildAttribute` → `FindChild`). The next
step would be to evaluate each event once per distinct filter rather
than per item, which is out of scope here.

## Notes for reviewers

- `EventMonitoredItemQueue.m_events` keeps its protected
`List<EventFieldList>` type, but dequeued slots now stay null at the
front until compaction. The queued events are the last `ItemsInQueue`
entries. Nothing in the repository reads the field outside the class
except the mirroring queue, which uses the new internal
`CopyQueuedEvents()`.
- Event field variables created by `Initialize` now report the event
time as `SourceTimestamp` instead of the time of the read.

## Related Issues

- No issue. Found by the server load investigation of publish-, write-
and event-heavy load.

## Checklist

- [ ] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [x] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [x] I have added all necessary documentation.
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed. (net10.0
only: Opc.Ua.Server.Tests 8990 passed, Opc.Ua.Core.Tests 6449 passed,
Opc.Ua.Redundancy.Server.Tests 922 passed. In the explicit Sessions
`ServerEventSubscribeLoadTest`, opc.tcp passes; https and opc.https fail
with HTTP 503, the same as on master.)
- [ ] 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-19 09:15:45 +00:00
romanettandClaude Opus 5.5 37a2a831f3 Server: keep the publish timer on a fixed cadence and parallelize the sweep by work (#4829)
The publish timer loop ran a pass over all session publish queues and
then
waited a full PublishingResolution, so the cycle period was pass time +
resolution and a slow pass delayed every following cycle. Whether the
pass
ran in parallel depended only on the session count (256 or more).

Fixed cadence: the loop now waits only for what is left of the period
(RunAtFixedPeriodAsync on the server TimeProvider). A pass that overruns
the period is followed by the next pass at once and the cadence restarts
from there; missed periods are not caught up with extra passes.
Shutdown, cancellation and abandoned subscription handling are
unchanged.

Parallelism by work: a pass checks the queues on the sweep thread until
it
has used a quarter of the publishing resolution (25 ms at 100 ms), then
checks the remaining queues with Parallel.For (CheckPublishTimers). The
session count no longer decides.

Why the budget:
- The pass cost is real per-item work. In the LoadTest subscribe shape
  (50 sessions x 15 subscriptions x 600 items, all 600 written every
  150 ms, publish 100 ms) every pass promotes up to 450k items; the
sequential pass takes 85-125 ms (~0.28 us per promoted item: ready list,
IsReadyToReport, LinkedList move, trigger check, ready signal release).
  Ready-list promotion already skips items without pending values and
  subscriptions whose timer is not due, so there is no idle work left to
  skip; only parallelism makes the pass fit the period.
- Fixed cadence alone lifts delivery from 64 % to 91 % (pass ~85 ms in a
  100 ms period, no headroom); with the parallel remainder 97-98 %.
- Parallel sweeps cost CPU when the pass fits anyway: at 10 sessions the
sequential pass takes ~15 ms; a 2 ms budget made it parallel and raised
  server CPU per notification from 5.0 to 8.9 us. With the 25 ms budget
  it stays sequential.
- Idle servers stay sequential: 1000 sessions x 1 slow subscription pass
  in ~2 ms sequential (was always parallel above 256 sessions); 2
  sessions in 0.05 ms.

Diagnostic probes (one build with temporary switches, one run each,
50x15x600 shape):

| loop / sweep            | delivered | us/notif | pass avg | cycle  |
|-------------------------|-----------|----------|----------|--------|
| old loop, sequential    | 63.9 %    | 5.1      | ~125 ms  | ~225 ms|
| fixed, sequential       | 91.2 %    | 5.1      | ~85 ms   | ~110 ms|
| old loop, all parallel  | 96.9 %    | 3.9      | ~24 ms   | ~132 ms|
| fixed, all parallel     | 97.0 %    | 4.1      | ~21 ms   | 100 ms |
| fixed, 25 ms budget     | 97.8 %    | 4.4      | ~44 ms   | 100 ms |

Paired A/B against master (perf/ServerLoadHarness, Server GC, encrypted,
alternating rounds, medians; runs repeated when processes outside the
harness used more than 1.5 cores):

| shape (sessions x subs x items) | n | delivered | us/notif | server
cores |

|-----------------------------------|---|--------------|-------------|--------------|
| 50 x 15 x 600, pub 100, write 150 | 3 | 67.7 -> 97.8 | 5.5 -> 4.4 |
11.3 -> 13.3 |
| 10 x 15 x 600, pub 100, write 150 | 3 | 100.4 -> 99.9| 5.5 -> 5.1 |
3.32 -> 3.11 |
| 1000 x 1 x 1, pub 1000, write 500 | 7 | 50 -> 50 (*) | 216 -> 201 |
0.21 -> 0.20 |
| 2 x 1 x 100, pub 100, write 150 | 3 | 100 -> 100 | 48.5 -> 46.1| 0.06
-> 0.06 |

(*) two writes per publishing interval with queue size 1, by design.

Side effect at 50 sessions: server allocation per notification rises
from ~54 to ~96 B. An allocation trace attributes it to
MonitoredItemNotification: the parallel remainder makes all
subscriptions publish within a few ms, the in-flight burst exceeds the
notification pool bound (kMaxPooledNotifications = 65536) and the pool
misses; the slow sequential sweep spread gets and returns. Gen0 GCs in
the 15 s window 3-5 -> 8, pause 20-50 -> ~60 ms; CPU per notification
still drops. Addressed by the stacked #4830, which allocates these
notifications instead of pooling them (tracking issue #4819).



### Follow-up commits

- **Late wake / exact deadline** (review): a wait that ends a whole
period late restarts the cadence from the late pass (no catch-up pass),
and a sweep exactly at a subscription's deadline counts as due.
- **Phase aliasing with equal periods** (f33893b7c): with the sweep at
the publishing interval, a subscription's deadline keeps one phase
relative to the sweep for the whole session. If it falls inside the ~16
ms timer jitter, a late sweep followed by an on-time one dropped a
publishing cycle. The 2-session shape (write = publish = 100 ms, queue
size 1) was bimodal: 6 of 24 runs at 56-85 %. The deadline now advances
by one interval per expiry and skips missed expiries only when a whole
interval behind after that. Result: 24 of 24 runs at 97-101 % (master:
steady 88-98 %); write 150 ms gave 98.5-100 %, and the 50 x 15 x 600
shape stayed at 99.3-100.7 %.
🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 06:48:41 +02:00
romanettandClaude Opus 5.5 014f99a53a WebSocket transport: a sender queued across Close fails as closed (#4832)
## Problem


`WebSocketByteTransportTests.CloseFailsQueuedSendInsteadOfOrphaningItAsync`
fails intermittently. One example is test-linux-net10.0 (3/16) in run
39114639155 on #4831, an unrelated MachineTool change. The queued send
failed with a raw `WebSocketException` ("…transitioned into an invalid
state") instead of `ServiceResultException` with `BadConnectionClosed`.

## Cause

This is a product race in `WebSocketByteTransportBase`.

- `Close()` cancels the shared send token. That wakes both the in-flight
send and the queued `SemaphoreSlim.WaitAsync`.
- `SemaphoreSlim` removes a cancelled waiter on a later continuation. If
the in-flight sender's `finally` releases the lock first, the release
*grants* the queued waiter despite its cancelled token.
- `EnterSendAsync` then returned normally, and the queued sender called
`SendAsync` on the already aborted socket.
- That socket error is not an `OperationCanceledException`, so it
reached the caller untranslated.

## Fix

After acquiring the lock, `EnterSendAsync` checks whether the transport
closed. If it did, it gives the lock back and throws
`BadConnectionClosed`. `Close()` sets the closed flag before it cancels,
so every waiter woken by `Close()` sees it.

## Tests

- The existing test body moved into a helper. New
`CloseFailsQueuedSendEvenWhenTheLockIsGrantedAsync` repeats the
interleaving 2000 times (about 140 ms).
- Without the fix it fails 3/3 runs within about 100 ms, with the CI
exception. With the fix it passes 3/3.
- `Opc.Ua.Core.Tests` `~WebSocket` (net10.0): 39/39 passed.

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 12:37:56 +00:00
romanettandClaude Opus 5.5 0005b0d5e6 WMTP: callers wait for the freshness tick instead of BadServerTooBusy (#4831)
## Problem


`WmtpMcpIntegrationTests.ReportsCopyExactWholeSelectionsWithoutConsumingEitherStore`
failed intermittently (test-linux-net10.0 13/16, run 38897795750, on
#4815, which only changes `perf/`). The `wmtp_count_records` call came
back with `BadServerTooBusy`.

## Cause

This is a product race. The UA request queue (MaxQueuedRequestCount /
MaxRequestThreadCount) is not involved, and the test makes no parallel
calls.

- `WmtpOperationCoordinator` admits one operation per peripheral and
refuses a second one with `BadServerTooBusy`.
- `WmtpPeripheral.StartMaintenance` starts a 1 s age timer (`AgeTick` →
`RefreshQualityAsync`) that takes the same gate.
- The Tools.Tests fixture clock does not override `CreateTimer`, so the
timer is a real system timer. A client call that arrived while a tick
was running was refused. On a loaded runner each tick takes longer, so
collisions become more likely.
- On a production server, any WMTP client call could randomly hit this,
and the client cannot tell it apart from a real concurrent operation.
- It did not start with #4812 or #4808. The code came in with #4765.

## Fix

- The active operation now records whether it is maintenance.
- Other operations wait up to one second for a running freshness tick
instead of being refused. A regular tick makes no equipment calls and
takes milliseconds.
- A stalled tick, for example one blocked in a state-changed sink, still
refuses the caller with `BadServerTooBusy` after that second. This keeps
`ControlledQualityMaintenanceStillExcludesOverlappingReportsAsync`
valid.
- A tick is still refused while any operation is active, and it retries
on the next tick, as before.
- Two client operations still conflict with `BadServerTooBusy`.
- The callback marker is now a chain of the peripherals a call passed
through. Re-entering a ticking peripheral through another peripheral's
callback (A → B → A) is refused instead of waiting for itself (review
feedback).

## Tests

-
`WmtpRuntimeTests.CallerArrivingDuringFreshnessTickWaitsInsteadOfBusyAsync`
holds a tick open through a `StateChangedAsync` sink. Without the fix it
fails with the CI error.
-
`WmtpRuntimeTests.IndirectReentryIntoTickingPeripheralIsRefusedInsteadOfDeadlockingAsync`
covers the A → B → A case. With the innermost-only check it times out.
- Local net10.0 runs:
  - `Opc.Ua.MachineTool.Tests` `~Wmtp`: 124/124 passed.
  - `Opc.Ua.Tools.Tests` `~WmtpMcp`: 176/176 passed.
  - `Opc.Ua.Machinery.Tests` `~CompanionSuiteWmtp`: 2/2 passed.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 13:42:46 +02:00
fc0687a2dc Source generator: a NodeSet2 Variable without a Value has a null Value (#4798)
# Description

A Variable or VariableType in a NodeSet2 that has no Value element has a
null Value (OPC 10000-6 F.5); the runtime NodeSet2 importer loads it
that
way. The source generator initialized such a node with the default value
of its DataType instead ((int)0, (double)0, ...), so a server built from
the generated code reported values the model never declared.

The NodeSet2 importer now records that the node declares no Value
(NodeSetValueIsNull), the flag follows the node into the merged instance
hierarchy, and the node state generator emits Variant.Null for it when
no
value was merged in from elsewhere. ModelDesign inputs, where a missing
DefaultValue implies the DataType default, are unchanged.

The tests generate a DI-style NodeSet2 together with the DI NodeSet2 and
check the generated values of a type's InstanceDeclaration, of an
instance's children with and without a Value, and of a VariableType.

## Related Issues

None.

## Checklist

- [x] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [x] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [x] I have added all necessary documentation.
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [x] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed.
- [x] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [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-18 09:16:26 +02:00
Wolfgang ColsmanandCopilot 4a7dfe7411 Server: release a restored event item that its node manager rejects (#4828)
# Description

Follow-up to #4795. When a node manager returns a Bad result from
`SubscribeToEventsAsync` for an event monitored item while durable
subscriptions are restored,
`NodeManagerServiceDispatcher.RestoreMonitoredItemsAsync` removes the
item from the event manager and from the result list, but nothing
disposes it, so its queues stay alive. The item is now disposed there.

A queue that was staged for the restore
(`IRestorableMonitoredItemQueue.BeginRestore`) keeps its stored contents
when it is disposed, as for any aborted restore, so a later restore
still finds them. The data-change restore path already disposes an item
it fails to restore, and the `Objects.Server` path keeps every item, so
neither needs a change.

A test in `SubscriptionRestoreFailureTests` restores an event item that
its node manager rejects and checks that the item is unregistered and
its queue disposed; it fails without the change.

## Related Issues

Follow-up to #4795.

## 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. (The
subscription restore tests of Opc.Ua.Server.Tests on .NET 10, 35 passed;
Opc.Ua.Server.Tests builds for net48.)
- [ ] 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>
2026-10-18 08:43:33 +02:00
8441351902 Client: let ManagedSession validate a rediscovered endpoint (#4791)
# Description

When a reconnect is rejected with a status that requires the endpoint to
be refreshed, ManagedSession reads the endpoint again from the server's
discovery endpoint and ConfiguredEndpoint.Update replaces the endpoint
in
use with whatever discovery returned. The application had no say: a
discovery answer that, for example, drops the security mode or presents
a
different server certificate was taken over as it was.

ManagedSession.ValidateRediscoveredEndpoint (a
RediscoveredEndpointValidator)
receives the endpoint in use and the rediscovered one before the session
is
recreated against it. A Bad result keeps the endpoint in use and fails
the
reconnect attempt with that result, so the reconnect policy decides what
happens next. Without a validator the behaviour is unchanged.

## Related Issues

None.

## Checklist

- [x] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [x] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [x] I have added all necessary documentation.
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [x] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed.
- [x] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [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-18 07:03:11 +02:00
Marc Schier 55bd944609 WoT: Types-side TD/TM-NodeSet conversion with consolidated corpus and minimal consumer compat (#4758)
# Description

Adds the pure Types-side WoT Thing Description / Thing Model to NodeSet
conversion: resolution, context, declarations, types, values,
localization, URI/format handling, bounded hashing, plus supporting
native XML codec, NodeSet alias/value helpers, RelativePathFormatter and
a JsonElement equality polyfill.

- Carrier-witness fix: a missing native event is accepted only when the
owning preserved type declares it through a `GeneratesEvent` reference
(directly or via its type/supertype chain) AND a bounded, validated
selected EventType carrier identity equals the normalized authored
native target. Native-present events get a full compare; an ambiguous
native key and a missing map stay strict; a readable document is never
archive authority.
- Support-only dependencies: additive per-instance, non-serialized
`WotDocument.IsSupportOnly(kind, name)` (ArgumentNullException guard).
The resolver marks support-only after closure; explicit selection wins;
the View builder excludes only true support-only members, so unit/actsOn
dependencies stay in the JSON. Nested controls cover inner value-only
projection under an outer value-only selection (unit kept, support-only)
versus an outer explicit SelectAll (explicit, not support-only).
- Documentation/XML comments and member-order only in commit 5c368d3a;
no logic change.
- Timeout repair on current head 6957dd46: invocation-scoped
`ArchiveDerivations` reuse removes repeated document-set derivation work
without a global cache (functional files
`WotNodeSetConverter.{Archive,ArchiveDerivations,Partitions,WoT}.cs`,
plus `WotArchiveDerivationsTests` and
`WotDocumentSetSharedValidationTests`). The original local full29
reproduction changed from 27 passed / 2 Refresh timeouts to 29 passed
with unchanged limits; official CI on this head FAILED at three
per-project time ceilings (see the evidence ledger). Temporary RF1
diagnostics were fully removed; the final tree differs from 0f7b9a09
only in the six functional files.
- Test-only net48 compile repair in commit b838907 (earlier head):
official CI on 5c368d3a was RED on the net48 build with CS1061 (see
ledger); the fix adds `using System.Threading;` and an unqualified
`CancellationTokenSource` in one test file. No production change.

Defaults: DI default document budget 256 versus the sample's explicit
1024. Corpus: canonical 540-JSON inventory; the actual manifest is 535
entries = 394 TD + 141 TM (the roles of the other 5 are not claimed).
Native Pumps assertions (9624 nodes / 339 partitions) are unchanged.
2026-10-17 20:43:25 +02:00
768b39e1be Server: surface failures to restore durable subscriptions (#4795)
# Description

SubscriptionManager.RestoreSubscriptionsAsync logged and swallowed every
restore failure: a subscription store that threw, a store that reported
an unsuccessful restore, and a stored subscription that could not be
recreated. The server then started without those subscriptions, the
store was told the restore had completed (so it cleaned up what it
held),
and nothing but a log line showed that subscriptions had been lost.

A failed restore now fails the restore, and with it the startup:
- a store that throws fails with BadInternalError and the store's
  exception as the inner exception;
- a store that reports Success = false fails with BadInternalError;
- stored subscriptions that cannot be recreated are collected and
  reported as an AggregateException, before the store is told that the
  restore completed, so the store keeps what it holds.

SubscriptionManager.ContinueOnRestoreFailure (default false) restores
the
previous behaviour for applications that prefer to start without the
subscriptions. Cancellation is no longer swallowed unless that option is
set.

The Quickstarts durable subscription store reported Success = false when
it had nothing stored; it now reports a successful restore of no
subscriptions, and keeps reporting a failure when its file cannot be
read.

## Related Issues

None.

## Checklist

- [x] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [x] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [x] I have added all necessary documentation.
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [x] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed.
- [x] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [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-17 20:40:45 +02:00
Marc SchierandCopilot Autofix powered by AI 7fb986bd0d Add recycling operator console and sample container images (#4810)
# Description

Add a local browser-based operator console for the synthetic recycling
line, using direct, encrypted OPC UA connections and the existing typed
coordination interface.

- Extract a sample-local, non-packable `Recycling.Client` library shared
by the server and console. Preserve the model XML bytes, namespace/type
identifiers, resource identity, wire format and journal schema; the
server retains its .NET 8/9/10 targets.
- Add a .NET 10 ASP.NET Core workstation host with bounded SSE
state/trend delivery, native companion inspection, paged
material/genealogy reads, and role-limited coordinator commands. Exact
command envelopes are retained for explicit retries; uncertain outcomes
are not automatically reissued.
- Add the operator interface using local HTML/SVG, CSS and JavaScript
modules: ten process groups, substep inspection, faults/constraints,
persistent quality holds, trends, material tracing and simulator
administration. Show raw quality/provenance and handle stale data, run
changes, browser expiry and large identifiers explicitly. No
browser-generated plant telemetry or frontend framework/npm build is
introduced.
- Keep application/user credentials in external certificate stores. Use
strict X.509 trust, signed/encrypted UA sessions, local browser
bootstrap sessions, same-origin/CSRF checks, and a conservative
configured control ceiling. This is a local synthetic sample, not a
physical emergency-stop or shared remote-user system.
- Register `recyclingserver` and `recyclingclient` in the existing
Images CI matrix, using source/prebuilt Dockerfile stages and the
existing portable-IL/multiarchitecture packaging flow. Add an optional
loopback-published Compose console service. No new schedules or NuGet
publication entries are added.
- Add managed, real-UA, Chromium, NativeAOT and image-contract
qualification, with Microsoft.Playwright as a test-only centrally
managed dependency, and update usage/deployment documentation.

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

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-10-17 16:16:29 +02:00
romanettandClaude Opus 5.5 44a8cb277d plans/ctt-issues.md: record C60 (CTT publish thread stall in the A&C Limit/Level CUs) (#4820)
The CTT regression run on master (716858aa7) reported 6 Pass → Error
regressions in the A&C part:
A & C Exclusive Limit `Test_003.js`–`Test_006.js` and A & C
Non-Exclusive Level `Test_003.js`/`Test_004.js`.

They are not a server regression:

- On the GitHub runner, the A&C part failed in 6 of 9 runs: master 3 of
6, and a14b23e7d (the last build that had passed) 3 of 3.
- A traced server answered every Publish request of the affected session
(413 requests, 413 responses). After that, the CTT sent only Read
requests on the same channel, and the session's Subscriptions expired.
- Locally (13 runs, also pinned to 4 cores) the A&C part never failed.

The trigger is the CTT CI's default publishing interval of 150 ms, which
keeps the five Subscriptions of the Limit/Level CUs permanently due for
a keep-alive. The CTT CI now uses 250 ms for the A&C part: 3 of 3 A&C
runs and one full run passed, with 0 regressions against the baseline.

This PR only records the defect as C60 in `plans/ctt-issues.md`. There
is no code change.

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 07:57:07 +02:00
849840be5d perf: publish/write/event load scenarios for the server load harness (#4817)
Builds on #4815 (merged); rebased onto master. Two commits, touching
only `perf/`.

Extends `perf/ServerLoadHarness` and `perf/TraceAnalyzer` to profile
publish-, write- and event-heavy server load that mirrors the explicit
Sessions `LoadTest`. Built for the publish/write load investigation:
[findings report](https://claude.ai/artifact/BWuEEPcvvbTAbjY44mJFsa).

## New options

| Option | Scenario | Meaning |
|---|---|---|
| `--scenario events` | | Event subscriptions on the Server object; a
server-side `EventGenerator` (in `EventWorkload.cs`) reports events,
driven by the server child's new `events <rate>` / `events stop` stdin
commands. |
| `--event-rate` | `events` | Events/s the server reports; 0 = as fast
as one thread can. |
| `--writers N` | `sub` | Dedicated writer sessions in addition to
`--sessions` (0: the first subscriber session writes, as before). |
| `--write-interval 0` | `sub` | Closed-loop writers instead of a timer.
|
| `--write-batch` | `sub` | Values per Write; writers rotate through the
nodes in slices. |
| `--node-type all` | `sub` | The LoadTest's Int16…UInt64 mass node set
(up to 600 nodes). |
| `--queue-size` | `sub`, `events` | Monitored item queue size (default
1 / 10000). |
| `--publish-requests` | all classic | Minimum outstanding Publish
requests per session. |

Also:
- `Workload.Secondary` / `OnServerReport` hooks and a `per-op2:` result
line: server cost per written value (`sub`) or per generated event
(`events`).
- TraceAnalyzer `threads` mode: busy share of each thread with its main
roles (finds serialized stages).
- TraceAnalyzer `cpu <trace> <top> <focus>` additionally lists the focus
frame's direct callees.
- README rows and paragraphs for all of the above.

Conflicts with #4815's rewrite (native workers, `suspend`/`samples`
modes) were resolved so #4815's code and text stay and these additions
are layered on top.

## Verification

Builds: `CustomTestTarget=net10.0 dotnet build perf/ServerLoadHarness -c
Release` and `dotnet build perf/TraceAnalyzer -c Release`: 0 warnings, 0
errors.

Smoke tests (`--duration 5 --warmup 3`, `DOTNET_gcServer=1
SERVER_DOTNET_gcServer=1`), each run only once foreign CPU was below 1.5
cores, with foreign CPU measured during the run (avg 0.5–0.6 cores):

| Run | Result |
|---|---|
| `events --sessions 5 --subs 3 --event-rate 2000` | generated/s=2009,
events/s=30413, received/expected=100.9 %; `per-op2` 588.9 µs CPU / 31.5
KB per generated event |
| `sub --sessions 5 --subs 3 --nodes 600 --node-type all --writers 4
--write-interval 0 --write-batch 100` | 131k values/s written, 77k
notif/s, writeErrors=0; received/expected=3.9 % (queue size 1 with
closed-loop writers: values are overwritten before publishing, as
intended); `per-op2` 50.5 µs / 1680 B per written value |
| `sub --sessions 2 --subs 1 --nodes 100 --queue-size 2
--publish-requests 4` | received/expected=98.0 %, writeErrors=0 |
| same as the second run with `--trace` | 23 MB trace; `TraceAnalyzer
threads` lists 58 threads with busy shares and roles; `cpu … 10
MonitoredItem.Publish` prints callers, first Opc.Ua frame and the new
"direct callees of" section |

#4815's own scenarios still parse: `counters` passes; `native-read`
(with `--server-affinity`, `--thread-states`) and `native-counters`
start the contract server and fail only at launching the (absent) native
worker executable, as expected without the workers.

🤖 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-17 07:56:43 +02:00
Marc Schier 7e25e1a3d7 Extract remaining shared companion, rendering and packaging changes (#4806)
# Description

The shared AI deployment contracts and instance registration; DI
software-upload outcomes, digest handling and simulator; bounded
Positioning/Pumps reads; PubSub raw-data/matrix and security-provider
handling; Vision read-quality contracts; xRegistry resource writes and
instance projection; OpenUSD authoring, embedded viewport and
retained-frame capture APIs; standalone sample CLI controls; and the
generic Debug/RID package-policy and fork/Dependabot coverage-comment
fixes. The Linux native prerequisite step is retained for **Vision tests
only**.

## Related Issues

- Related tracking issue:
[#4461](https://github.com/OPCFoundation/UA-.NETStandard/issues/4461),
without automatic closure.
- Source work:
[#4462](https://github.com/OPCFoundation/UA-.NETStandard/pull/4462),
which remains intact for the coordinator's later integration.
- Immediate stacked predecessor:
[#4804](https://github.com/OPCFoundation/UA-.NETStandard/pull/4804).

## 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.
2026-10-17 07:52:02 +02:00
4e77ba3f84 Redundancy: snapshot and restore for RaftSharedKeyValueStore (#4794)
# Description

RaftSharedKeyValueStore applies every committed command but offers no
way
to capture its state. An application that wants to bound the Raft log
cannot snapshot the store before compacting its consensus log, and after
a
restart it cannot restore the state without replaying the whole log.

Add CreateSnapshotAsync, which commits a barrier through the consensus
and
encodes the state in a versioned format, and RestoreSnapshot, which
replaces the state before the store starts applying the log. A malformed
snapshot raises FormatException; restoring after start raises
InvalidOperationException.

Tests: RaftSharedKeyValueStoreSnapshotTests (round trip, log applied
after
restore, restore after start rejected, corrupt snapshot rejected).

## Related Issues

None.

## Checklist

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Marc Schier <marcschier@users.noreply.github.com>
2026-10-16 20:10:13 +02:00
2de64d7d93 Source generator: keep NodeSet2 references to nodes of referenced models (#4796)
# Description

A reference in a NodeSet2 input whose target is a node of another model
was
dropped without a diagnostic when that model is supplied by a referenced
assembly rather than by an AdditionalFiles input. The dependency
metadata
of a referenced assembly carries types only, so the importer could not
resolve an instance such as DI's DeviceSet and skipped every reference
to
it: a device organized by DeviceSet (inverse Organizes) lost that
reference, and so did any non-hierarchical reference to such a node.

When the target of a reference is in another, non-zero namespace and no
loaded design defines it, the importer now creates a stand-in that
carries
only the NodeId the generated AddReference call needs. The stand-in is
not
generated. References whose reference type is unknown are still skipped.

Tests generate a DI-style NodeSet2 whose device is organized by
DeviceSet
against DI supplied as a referenced assembly and as an AdditionalFile,
and
compile the result against the stack.

## Related Issues

None.

## Checklist

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Marc Schier <marcschier@users.noreply.github.com>
2026-10-16 20:09:14 +02:00
Marc Schier b1c0cd20fd Fix classic browse-path subscription restoration (#4814)
# Description

Persisted classic subscriptions retain their server/client handles but
previously lost relative-path resolution metadata. Transfer could
succeed, yet a later `ApplyChangesAsync` attempted to resolve an
already-created item and failed. The SDK now owns this restoration
lifecycle without exposing a public resolution-state setter.

- Preserve the originally monitored target in internal snapshot state
and restore both `ResolvedNodeId` and `Status.NodeId`.
Transfer/reactivation use handles without re-browsing; fresh creation
resolves paths again.
- Version classic subscription/configuration files and read existing
unversioned 2.0 files using frozen legacy wire layouts. Legacy missing
targets remain unknown rather than being guessed.
- Exclude existing items from creation-time resolution, reserve creation
before asynchronous translation, and reject stale translation results.
Failed deletion retains identity and pending retry state.
- Support forward-only snapshot input using position tracking and
bounded, per-ExtensionObject buffering in the binary decoder, including
short reads. No whole-file buffering or reflection-based serialization
is introduced.
- Add unit/integration regressions and document the supported lifecycle
and migration implications.

**Compatibility:** Newly written files require the updated SDK. Existing
2.0 files remain readable. Assigning a different `StartNodeId` or
`RelativePath` to a created/in-flight item now immediately throws
`BadInvalidState`; unchanged assignments remain allowed. No V2
subscription-engine changes or 1.5 XML import are included.

## Checklist

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

- [ ] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [ ] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [x] I have added all necessary documentation.
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed.
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [ ] I have addressed **all** PR feedback received.
2026-10-16 20:06:51 +02:00
romanettandClaude Opus 5.5 8e96408b2a Fuzz oracles: confirm net48 allocation findings by re-running the target (#4818)
## Problem

`FuzzCrashAssets(AflfuzzBinaryDecoder)` failed intermittently on net48
(run 38897795750, test-windows-net48 1/16, on #4815, which changes only
`perf/`):

> 10562496 bytes allocated for 42 input bytes, budget 8391296 bytes

On .NET Framework, `FuzzOracles` measures allocations with
`AppDomain.MonitoringTotalAllocatedMemorySize`. That counter covers the
whole AppDomain, so it also counts other threads in the test host.

Measured on net48 Release for
`crash-0bdcbfafc13981ae6f665ad52cabc1cbe21b0238`:

| Scenario | Bytes |
|---|---|
| First decode in the process (one-time XML setup) | 1,083,232 |
| Later decodes, same thread or a fresh thread | 16,440 |
| Same decode with one concurrent thread allocating | 10.7 to 23.8 MB
(over budget) |

The decoder does not have a per-thread cost, and #4812 did not regress
it. The CI TRX shows that tests ran one at a time. The failure came
about 2 s into the run, right after a burst of about 100 fast test
cases, which fits vstest serializing results on a background thread.

## Fix

`FuzzOracles.RunTarget` re-runs a target when the allocation finding
comes from a counter that is not per thread (`AllocationConfirmationRuns
= 2`). It reports the finding only if every run confirms it. A real
over-budget decode allocates the same amount every time, while a burst
on another thread rarely lines up with all three runs. This is the same
approach the time oracle already uses (`TimeConfirmationRuns`). Runtimes
with a per-thread counter (net8+) behave as before. `FuzzMethods`
playback goes through `RunTarget` too, so it also gets the confirmation
runs.

## Tests

- New tests in `FuzzOraclesTests`:
- A finding that every run confirms is still reported, after 3 runs on
net48 and 1 run on net8+.
- A finding inflated by another thread is ignored (net48 only). This
test fails when the confirmation runs are set to 0.
- net48 Release: Opc.Ua.Fuzzing.Tests 156/156,
Opc.Ua.Encoders.Fuzz.Tests 8130/8130.
- net10.0: FuzzOraclesTests pass.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 17:21:59 +02:00
romanettandClaude Opus 5.5 8b56937fdf perf: drive the server with external native benchmark workers; TraceAnalyzer GC modes (#4815)
## What this adds

Harness-only changes under `perf/` (not part of `UA.slnx`, `net10.0`).
No product code changes.

### External native worker scenarios (`ServerLoadHarness`)

Two new scenarios run the native open62541 workers of the

[o6-automation/opcua-benchmarks](https://github.com/o6-automation/opcua-benchmarks)
project
against a server, so the .NET server and other OPC UA stacks are
measured with the same client
as that project's published comparison:

| Scenario | What it measures |
|---|---|
| `native-read` | Fixed-load scalar Read throughput
(`throughput/open62541/client.c --worker async`): `--clients` worker
processes, each with `--inflight` single-value Reads of
ns=1;i=1001..1100 in flight, starting together. Reports aggregate req/s,
and server CPU and allocated bytes per request. |
| `native-counters` | Subscription capacity
(`subscription/native/client.c`): one subscription with `--nodes` UInt64
counters at `--sampling` 10 ms / `--pub` 1000 ms, queue size 1. Passes
when every item gets 99 % of the samples its sampling interval calls
for. |

The result block has the same lines as the other scenarios, and the CPU
time of the native
workers goes in `client-per-op:`.

The server is either the harness child in **contract mode** (`server
--contract 1`) or any
external executable (`--server-exe`, `--server-args` with `{port}`,
`{items}`, `{sampling}` and
`{pki}` placeholders), for example the benchmark's open62541 server or
one of its .NET workers.

By default the harness child is configured like the benchmark's .NET
worker for
UA-.NETStandard 2.0.1:

| Setting | Default | Override |
|---|---|---|
| Node managers | `AsyncCustomNodeManager` | `--async-node-manager 0`
(`CustomNodeManager2`) |
| Counter increment | under a lock owned by the counters |
`--counter-lock 0` (interlocked) |
| Garbage collector | Server GC | `SERVER_DOTNET_gcServer=0` |
| `MaxQueuedRequestCount` | clients × in flight + 3 (minimum 200) |
`--queue` |

Other options: `--native-client`, `--clients`, `--inflight`,
`--iterations`, `--warmup-calls`,
`--server-affinity` (processor mask; combine it with
`SERVER_DOTNET_PROCESSOR_COUNT`, because the
mask is applied after the runtime has sized itself),
`--client-affinity`, `--server-priority`,
`--thread-states 1` (samples Running/Ready server threads and appends a
`sched:` line), `--security encrypt`
(Basic256Sha256 SignAndEncrypt with generated DER identities) and
`--verbose`. Also, the
`MinRequestThreadCount` of the server child is now the smaller of 50 and
`--threads`, so
`--threads` below 50 takes effect.

**Worker protocol.** The workers define a few names that are kept as
string literals in named
constants: the environment variables `O6_BENCHMARK_SECURITY_POLICY`,
`O6_BENCHMARK_CERTIFICATE`
and `O6_BENCHMARK_PRIVATE_KEY`, the `O6_BENCHMARK_READY` stdout token,
the identity
application URI / subject prefixes, and the server URI
`urn:o6:benchmark:server`. The workers
need that server URI because the contract puts its nodes in the
namespace of the server URI and
the workers address them as ns=1. Identifiers, file names, scenario and
option names are neutral.

### TraceAnalyzer

- `cpu` with a focus frame also reports the first `Opc.Ua` frame above
it, past framework
  wrappers such as `Monitor.Enter` or `SemaphoreSlim`.
- **GC-poll attribution:** the thread-time sampler suspends running
threads at their next GC
poll, so many on-CPU samples have `Thread.PollGCWorker` as their leaf.
`cpu` used to drop
them as waits (in one trace only 3.5k of 20k on-CPU samples were kept);
it now attributes them
  to the caller.
- `gc` mode (for `--trace-profile gc`): collections by generation, type
and reason; the
suspension time and the whole pause, each with count, total and
percentiles.
- `samples` mode: all samples by type (managed/external) and by leaf and
first `Opc.Ua` frame,
  including the ones `cpu` leaves out.
- `suspend <trace> [thresholdMs]` mode: every SuspendEE longer than the
threshold, with the
events recorded inside it by thread (an `AllocationTick` there means a
thread that had not
reached a safe point yet); event counts including the heap count DATAS
chose and GC handle
  churn by kind.

## Building the workers

The benchmark repository is AGPL-3.0, so nothing of it is vendored here.
Clone it and build the
workers separately. On Windows they build with `zig cc` and CMake
against the open62541 commit
the benchmark pins (OpenSSL backend, `UA_ENABLE_DEBUG_SANITIZER=OFF`,
Release). The
subscription workers also need `poll()` on stdin replaced by
`PeekNamedPipe`, plus unbuffered
control output. `perf/README.md` describes all of this.

## Commits

1. `perf: drive the server with external native benchmark workers`
2. `perf: configure the contract server like the benchmark's 2.0.1 .NET
worker`
3. `perf: TraceAnalyzer gc and samples modes, samples taken at GC polls`
4. `perf: scheduling and GC-suspension diagnostics for the native worker
runs`

Each commit builds with 0 warnings
(`CustomTestTarget=net10.0 dotnet build perf/ServerLoadHarness -c
Release`,
`dotnet build perf/TraceAnalyzer -c Release`), and `dotnet format
whitespace` makes no changes.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 14:35:16 +00:00
romanettandClaude Opus 5.5 c3701e5804 CI CTT: call the private repository through Actions REST endpoints only (#4816)
The first run after configuring the GitHub App
([run](https://github.com/OPCFoundation/UA-.NETStandard/actions/runs/38895888727))
minted its token correctly but failed on the dispatch:

> unable to determine default branch for
OPCFoundation/UA-.NETStandard-CTT: GraphQL: Resource not accessible by
integration (repository.defaultBranchRef)

`gh workflow run` (and other `gh` commands) make GraphQL calls that need
Contents access, but the App only has Actions read/write. Dispatch, run
lookup, polling and the summary download now use `curl` on the Actions
REST API, so the App needs no more than Actions read/write.

## Test plan
- [x] `bash -n` on the step script
- [x] Run-list filter, run status and `ctt-public-summary` artifact
download checked with `curl` against the private repository (earlier run
`repeat-1`)
- [ ] After merge: Actions → CTT regression → Run workflow on master

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 13:01:59 +00:00
716858aa7d Source generator: option to emit exactly the nodes of a NodeSet2 (#4797)
# Description

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

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

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

## Related Issues

None.

## Checklist

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

---------

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

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

## Related Issues

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

## Checklist

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

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

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

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

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

### Validation

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

</details>

## Related Issues

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

## Checklist

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

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

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

---------

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

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

### Results

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

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

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

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

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

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

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

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

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

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

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

## Changes

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

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

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

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

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

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

### Not done

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

## Behaviour notes

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

## Testing

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

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

---------

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

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

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

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

The review corrections are:

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

### Review regression evidence

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

### Validation

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

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

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

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

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

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

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

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

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

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

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

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

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

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

</details>

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

## Related Issues

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

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

## Checklist

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

- [ ] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [ ] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [x] I have added all necessary documentation.
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed.
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [x] I have addressed **all** PR feedback received.
2026-10-15 21:08:24 +02:00
Marc SchierandCopilot 1f6700c30b Fail closed when reconnect cannot preserve endpoint security (#4809)
# Description

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

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

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

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

## Related Issues

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

## Checklist

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

---------

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

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

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

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

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

## Related Issues

None.

## Checklist

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

</details>

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

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

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

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

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

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

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

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

& $dotnet --version
# Passed: 10.0.401.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

</details>

## Related Issues

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

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

## Checklist

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

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

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

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

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

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

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

### Conformance and scope

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

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

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

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

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

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

## Related Issues

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

## Checklist

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

- [ ] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [x] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [x] I have added all necessary documentation.
- [ ] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed.
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [x] I have addressed **all** PR feedback received.
2026-10-15 15:15:46 +02:00
romanettandClaude Opus 5.5 5bd3e116e1 Server request path: no large struct copies, slim per-request system context, no string table per request (R2, R5) (#4805)
## Description

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

### Results

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

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

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

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

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

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

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

## Changes

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

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

## Behaviour notes

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

## Testing

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

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

---------

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

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

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

## Related Issues

None.

## Checklist

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

---------

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

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

No wire, configuration or public API change.

## Results

Paired A/B against master `af601ebca` (= `5b03e9afa` + #4778). The
builds alternate per round, with the order rotated each round, on the
same machine. The table shows medians of 5 rounds. The machine was
shared with other work; allocated bytes per request are deterministic.

- **o6read** uses the native open62541 client workers of the
o6-automation benchmark, in the o6 2.0.1 configuration:
`AsyncCustomNodeManager`, Server GC, queue = clients × outstanding + 3.
- **"4 cores"** pins the server to 4 cores (`--server-affinity 0xF`,
`DOTNET_PROCESSOR_COUNT=4`).
- **The other rows** use the harness's .NET client, 20 sessions unless
noted.

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

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

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

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

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

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

  The same tests pass on master.
- **Local, net10.0.** With this PR alone: Opc.Ua.Core.Tests Stack.Tcp +
Stack.Transport, 847 passed, 0 failed. With both PRs applied (all 0
failed):
- Opc.Ua.Core.Tests (Stack.Server, Stack.Tcp, Stack.Transport): 2753
passed;
  - Opc.Ua.Server.Tests: 8750 passed;
  - Opc.Ua.Sessions.Tests: 857 passed;
- Opc.Ua.Redundancy.Server.Tests: 884 passed;
Opc.Ua.Redundancy.Client.Tests: 134 passed;
  - Opc.Ua.Stress.Tests Contract + Integration: 45 passed.

net48 builds. `dotnet format whitespace --verify-no-changes` passes on
the changed files.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 12:53:19 +02:00
romanettandClaude Opus 5.5 19a97da52e Server: confirm unchanged isolation classifications at dispatch without the full lookup (#4787)
Roadmap item R3 of the o6 benchmark gap analysis (master `5b03e9afa`
profile). Each request was revalidated twice by the resource-isolation
provider: at dequeue, and again before execution. Each revalidation
repeated:
- the owner table lookup (a `ConditionalWeakTable`);
- the session binding lookup (token and session dictionaries, the
session's expiry clock);
- the classifier;
- the owner key checks.

Now the classification also returns the evidence it was derived from.
The queue confirms that evidence at dispatch and runs the full
revalidation only when it no longer holds.

No wire, configuration or public API change. Every rejection status
code, and the order of the checks relative to cancellation and session
close, is unchanged: the confirmation only ever short-circuits to Good,
and the full revalidation decides every other outcome.

## Results

Paired A/B against master `af601ebca` (= `5b03e9afa` + #4778). The
builds alternate per round, with the order rotated each round, on the
same machine. The table shows medians of 5 rounds, with the throughput
range of the rounds in brackets. The machine was shared with other work;
allocated bytes per request are deterministic.

- **o6read** uses the native open62541 client workers of the
o6-automation benchmark, in the o6 2.0.1 configuration:
`AsyncCustomNodeManager`, Server GC, queue = clients × outstanding + 3.
- **"4 cores"** pins the server to 4 cores (`--server-affinity 0xF`,
`DOTNET_PROCESSOR_COUNT=4`).
- **The other rows** use the harness's .NET client, 20 sessions unless
noted.

| Scenario | req/s | Server CPU µs/req | Alloc B/req |
|---|---|---|---|
| o6read 10×100, 4 cores | 130,300 → 135,400 (+4 %) [129–135k →
132–140k] | 30.3 → 28.6 (**−6 %**) | 4,706 → 4,709 |
| o6read 10×100 encrypted, 4 cores | 116,300 → 122,500 (**+5 %**)
[113–119k → 118–126k] | 33.4 → 31.5 (**−6 %**) | 4,747 → 4,756 |
| o6read 10×100, 24 cores | 344,500 → 347,400 (+1 %) [328–350k →
331–362k] | 53.6 → 52.2 (−3 %) | 4,710 → 4,716 |
| o6read 10×1000, 24 cores | 310,800 → 307,300 (−1 %) [297–332k →
307–323k] | 55.6 → 57.5 (+4 %, noise) | 4,719 → 4,730 |
| read --nodes 1, 20 sessions | 189,500 → 198,900 (+5 %) | 49.7 → 48.2
(−3 %) | 4,624 → 4,623 |
| read --nodes 1, 200 sessions | 174,500 → 179,100 (+3 %) | 50.2 → 44.7
(**−11 %**) | 4,510 → 4,521 |
| browse | 36,490 → 35,750 (−2 %) | 179 → 175 (−3 %) | 39,004 → 39,016 |
| sub (5 × 100 items, 100 ms) | 94,020 → 93,180 notif/s | 8.2 → 9.2
(rounds 4.5–12.7) | 145 → 145 |

**Where it helps:** CPU per request falls by 6 % on 4 cores, plain and
encrypted, and by 3–11 % in the read scenarios with the .NET client.

**Where it does not:** on 24 cores, CPU per request is within the spread
of the rounds. There the cost is dominated by workers of one channel
waiting for each other (the request hop and the send-order lock), which
this PR does not change.

### Profile
cpu trace, 24 cores, 10×100, quiet runs at 433–439k req/s. The trace was
taken with this PR and #4788 (R7) together; the frames below belong to
this PR only.

| Frame | master | this PR |
|---|---|---|
| `FairRequestQueue.GetRevalidationStatus`, inclusive | 15.4 % | 14.0 %
|
| of which the provider and session lookups | 15.4 % | 3.9 %
(`IsAdmissionCurrent`, mostly the session's expiry clock) |
| `SessionManager.TryGetSessionContext`, inclusive | 14.6 % | 0.2 %
(admission only) |

The lookups are gone, but the dequeue-time check still accounts for
about 10 % of the samples as its own time. That is the first touch of
the request (entry, request object, channel context) on the worker
thread, right after the receive thread wrote it on another core. On
master the same cache misses showed up inside `TryGetSessionContext`,
which is why the profile share (16.5 %) overstated what this change
could save. Removing the request hop (separate work) is what removes
those misses.

## Changes

### Evidence with the classification
- **Internal interface.** `DefaultServerResourceIsolationProvider`
implements a new internal interface,
`IResourceIsolationAdmissionEvidence`. Its `Classify` also returns the
evidence: the issued owner identity, which holds the channel snapshot
and the session binding.
- **Kept on the entry.** `FairRequestQueue` keeps the evidence, an
internal `ResourceIsolationAdmissionEvidence` record that only the
issuing provider interprets, on the queue entry.
- **Confirmation.** Before the full revalidation, the queue asks the
provider whether the evidence still holds. This applies to both checks,
at dequeue and before execution. The provider:
- compares the channel snapshot with the request's channel context, as
before, so mutated channel evidence is still detected;
  - asks the binding's liveness check.

  Only a confirmed Good is short-circuited.
- **No evidence, so every revalidation runs in full:**
- a classifier is configured, because it may map the same inputs
differently at any time;
- the request has a token but no binding, because an activation may bind
it while the request waits;
- the binding comes from another binding provider, which has no liveness
check.

### Binding liveness (`SessionManager`)
A `SessionBindingContext` issued by `SessionManager` carries an internal
liveness check. It runs the checks of `TryGetSessionContext` that can
change while the binding and the request's channel context stay the
same, without the token and session dictionary lookups:
- **The dispatch binding is still this binding.** A removal, a new
activation, an identity change, a transfer and an activation in commit
all replace it. A commit always creates a new instance.
- **The session's channel certificate was not replaced.**
`SetRestoredSessionTransferSecurityState` can replace it without a new
binding, so the certificate has a version.
- **The manager is not shutting down.** Shutdown empties the token index
without replacing the bindings.
- **The session itself** is activated, not closing or claimed, not
expired (the clock-based `HasExpired`, including expiry before the
monitor closes the session), and still valid for the channel. These
checks run in the same order as in `TryGetSessionContext`.

A replaced binding releases its session, so owners that still reference
the binding do not keep a closed session alive.

## Testing
- **`RuntimeResourceIsolationTests` (new):**
- the evidence confirms only an unchanged binding and channel, and a
mutated certificate ends it without consulting the session;
- it is withheld with a classifier, for a binding without liveness, and
for a token without a binding;
  - a disposed provider throws;
  - the check allocates nothing.
- **`SessionBindingTests` (new):**
- the liveness check agrees with a fresh `TryGetSessionContext` through
re-activation, transfer and close, an identity change, expiry before the
monitor closes the session, an activation in commit, a session that
starts closing, and shutdown;
- replacing the transfer-security certificate with
`SetRestoredSessionTransferSecurityState` keeps the binding but ends the
liveness check (fails without the certificate version);
- a race of 1000 transfers and a close against 4 readers never confirms
a replaced binding, or any binding after the close.
- **`FairRequestQueueTests` (new):**
  - current evidence replaces both full revalidations;
- stale evidence at dequeue, or before execution, falls back to the full
revalidation and its status (`BadSessionIdInvalid`, `BadServerTooBusy`),
and the request does not run.
- **Local, net10.0**, with both PRs applied, all 0 failed:
- Opc.Ua.Core.Tests (Stack.Server, Stack.Tcp, Stack.Transport): 2753
passed;
  - Opc.Ua.Server.Tests: 8750 passed;
  - Opc.Ua.Sessions.Tests: 857 passed (379 skipped as on master);
- Opc.Ua.Redundancy.Server.Tests: 884 passed;
Opc.Ua.Redundancy.Client.Tests: 134 passed;
  - Opc.Ua.Stress.Tests Contract + Integration: 45 passed.

net48 builds. `dotnet format whitespace --verify-no-changes` passes on
the changed files.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 12:52:48 +02:00
Marc Schier d764161a53 Extract core/types fixes for schemas, replay, and source generation (#4801)
# Description

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

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

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

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

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

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

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

### Current CI-repair validation

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

### Earlier validation

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

## Related Issues

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

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

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

## Checklist

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

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