mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-20 00:31:32 +08:00
46fa7d364a
# Description Bound managed-session recovery, preserve Publish workers and recovery ownership, and address the client, server, transport, and certificate audit findings and subsequent concurrency review. Also reduce the memory retained by exhaustive session-test diagnostics. ## Recovery and Publish ownership - Add `ManagedSessionOptions.ChannelReconnectTimeout` through direct construction, the fluent builder, and DI. The automatic duration is `max(3 * KeepAliveInterval, revised SessionTimeout, OperationTimeout)`, sampled at cycle start. Positive overrides are supported; `Timeout.InfiniteTimeSpan` explicitly opts out. Raw sessions retain their unlimited default. - Add `CreateReconnectBudget(TimeProvider)` as a required `IReconnectParticipant` member, as agreed in review. Custom participants implement it and may return null for no participant-specific bound. - Enforce the earliest participant/caller deadline through transport reconnect, activation/recreation, and subscription restoration. Late callers can shorten an active cycle but cannot extend it. - Cancel and unwind recovery, fence late responses, and release channel recovery ownership before the existing `Faulted` event hands control to the outer reconnect/failover policy. Preserve session recreate admission through final Publish cleanup and keepalive suppression until shared recovery is complete. - Treat participant budget creation/evaluation failures, including exceptions at the exact deadline, as terminal shared-recovery failures. Notify participants before exposing `Faulted`, preserve the original diagnostic, and never silently replace a finite budget with unlimited recovery. - Keep caller-supplied replacement channels, managed bindings, wrapper event subscriptions, and recovery-owner identities consistent across success, failure, and cancellation. Reject replacement during active recovery and fence retired callbacks so they cannot clear newer ownership or suppress later keepalive recovery. - Retain eligible Publish workers through temporary subscription-identifier clearing without retaining workers for never-created or removed subscriptions. Preserve cancellation, acknowledgement rollback, and drain-before-replacement ordering. - Coordinate automatic subscription updates with session recovery: cancel admitted updates, keep them paused across a failed-restoration handoff, and retry only after explicit restoration completes. This prevents competing CreateSubscription requests and updates against a retiring session, while preserving manual recreation, transfer fallback, and pending option changes. - Exercise the real ManagedSession, V2 subscription engine, and channel ready gate with scripted transports and an injected clock. Cover transfer fallback, deadline handoff, late completions, and resumed notification delivery. ## Audit and review corrections - Correct certificate/trust-list transactions, change-pump lifetimes, validation, peer revalidation, and reset/resource ownership. - Harden node admission, attribute/range/dimension validation, monitored-item sampling and lifecycle transitions, event fanout, and server shutdown. Run lifecycle readiness and compensation outside shared monitored-item admission, with stale-completion ownership checks. - Close synchronous node-manager admission before teardown and retain resources until admitted service, lifecycle, and asynchronous method work returns. Dispose the monitored-item manager outside both locks, reject late calls before they can reach it, and forward asynchronous disposal through the adapter so the master awaits the same completion. Preserve reentrant shutdown initiation, cancellation/failure unwinding, and shared cleanup-failure reporting. - Bound WSS pending-request retention using connection-local wire handles while preserving caller-visible request handles and translating Cancel targets. Safely ignore retired replies and drain admitted send/close users before resource disposal. - Clarify that the shared `ClientBase` caller-handle sequence is distinct from the non-wrapping WSS wire allocator. Existing exhaustion/reopen, late-reply, and Cancel-translation regressions verify the connection-local contract; no allocator behavior was changed by this clarification. - Preserve virtual-node validation and resolver service errors instead of converting them to cached misses. A mismatched NodeId reports `BadNodeIdInvalid`; callback type mismatches and resolver service errors keep their exact status. Per-item boundaries preserve unaffected reads, writes, history operations, method calls, browse paths, and monitored-item creation/restoration, including permission-validation paths. Caller cancellation still propagates; stored-item validation failures are logged without aborting later restores. - Correct client file/role/state-machine/streaming APIs, node-cache behavior, redundancy/failover ownership, and session configuration/identity handling. - Cancel each owned peer-discovery scope before disposing it, including when the bounded wait wins a cancellation-delivery race. Keep late results out of the endpoint cache and preserve single-flight lookup ownership. - Keep materialized alias Browse results synchronized with the live store by default, preserving explicit startup-only opt-out. Apply HistoryUpdate service results per value while retaining explicit all-or-nothing provider APIs. - Update the directly related feature documentation and add focused positive, negative, concurrency, and ownership regressions. ## Latest review-feedback corrections - Fence stale failed lease observations with a successful-confirmation generation distinct from the revocation epoch. Snapshot it before each store read, including failed-CAS revalidation, so an old failure cannot revoke or reschedule a newer lease while fresh ownership loss still revokes leadership. Preserve independent overlapping acquisitions, expiry, disposal, and late-success protections. - Isolate attributable WSS response-decoding errors to their request, including retired handles. Keep admitted socket sends connection-owned so caller timeout/cancellation cannot abort sibling requests; shutdown still cancels and drains those sends. - Restart keepalive and classic Publish replenishment after successful session reuse. Retire V2 worker demand after intentional deletion/disable without confusing that state with temporary recovery resets. - Browse virtual directories lazily across continuation pages without the 1,024-entry snapshot cap. Preserve active-page cancellation, bounded metadata retention, pushback, and exactly-once asynchronous cursor cleanup. - Apply certificate/CRL staging in last-operation order, preflight required issuer storage before writes, select actual policy-compatible RSA keys for None endpoints, frame certificate chains independently of signature OIDs, and preserve admitted validation results during rejected-writer shutdown. - Preserve base asynchronous disposal when a legacy override omits its base call; avoid double derived disposal in the adapter. Let genuinely admitted nested work finish while rejecting stale captured contexts and returning null from closed lookup APIs. - Re-park recovered monitored items if AddNodes rolls back. Give event compensation a fresh finite deadline, and preserve healthy Browse/Translate siblings at every reference/path boundary while retaining starting-node errors and caller cancellation. - Give final reconnect notifications a live, bounded shutdown-linked lifetime. Synchronize child state before parent lifecycle handlers outside the parent transition lock. - Document the accepted existing policies: direct reverse connections are single-use without a ReverseConnectManager, and multi-node stream capacity is shared rather than reserved per node. The expanded readiness controls also exposed a real generation-handoff race: an old source could finish draining after activation was deferred but before readiness tasks were collected, causing the wanted replacement to be omitted from the wait set. A deterministic test gates that exact interval; the fix keeps the replacement in the readiness wait. The original race passed 200 repetitions after the fix. A separate control now awaits both producer readiness and iterator entry under the same original deadline, rather than treating those independent signals as equivalent. Temporary logging and repetition are removed. ## CI integration contracts The History and Server CI failures exposed integration tests that still asserted the behavior intentionally changed by the audit fixes. A Windows WoT negative test raced its caller deadline against upstream retries. A subsequent Ubuntu Client failure exposed a real automatic-update versus explicit-restoration race: - History insertion collisions now verify exact per-value statuses and all persisted timestamps/values, including preservation of the existing conflicting entry. Explicit atomic-provider rollback controls remain. - Alias materialization tests wait for actual publication and verify the added node plus forward/inverse `AliasFor` references, instead of racing a startup-only snapshot expectation. Explicit refresh opt-out controls remain. - The unavailable-upstream WoT test uses an explicit finite retry policy through existing DI configuration, preserving its original four-minute guard and requiring a mapped-value service error before caller cancellation. Production retry defaults remain unchanged. - The Client fix retains the exact one-create and late-response assertions. Three deterministic real-subscription/real-manager cases cover paused creation, disposal while paused, and cancellation of an admitted create followed by explicit restoration and a pending option update. Both composed restoration-deadline cases also prove automatic updates stay blocked at the outer handoff and resume after restoration. - The merged-head net48 correction prevents lost peer-resolver cancellation and distinguishes the harness's delayed initial `Connected` event from actual outer recovery completion. A deterministic timer-order test covers both cooperative and cancellation-ignoring resolvers, cancellation before return, rejection of late cached results, and a fresh subsequent lookup. - The remaining CodeQL alert was a false content flow through `ArrayOf.Count` into an integer-only trust-list summary. Projecting the same count through `Span.Length` keeps cardinality distinct from serialized contents without allocating, dropping diagnostics, suppressing the rule, or dismissing the alert. Five log-contract cases cover null/empty/nonempty results and mixed chain-capable/fallback listeners, asserting the exact count, message, and structured properties. - Classic transfer acknowledges messages selected for asynchronous Republish only after their responses arrive. The existing locked replay-selection decision returns the immediate-acknowledgement subset to Session, preserving the public transfer API, advertised availability, and immediate acknowledgement of older gapped messages not selected for replay. A callback changing `RepublishAfterTransfer` cannot change the already-selected acknowledgement ownership. - Transfer integration tests explicitly control acknowledged versus retained origin messages. Exact assertions distinguish retained replay from requested fresh initial values by sequence number, client handles, values, status, and source timestamp. Atomic counters, observed delivery/keepalive progress, and failure-safe session/file cleanup replace timing assumptions and racy resets. - Server lifecycle tests verify the documented independent five-second compensation deadline rather than requiring a non-cancellable token. They cancel the actual request, prove compensation and unrelated monitored-item creation still proceed one tick before the deadline, preserve the original failure and ownership assertions, and verify the completed deadline is retired. - Synchronous and asynchronous directory-browser tests require the exact provider exception instead of an empty-directory success, then verify the failed cursor is not enumerated again. These correct expectations left behind by the intentional, documented provider-error propagation change; production behavior is unchanged. No tests were disabled, assertions loosened, coverage thresholds lowered, or CI checks made optional. Existing platform exclusions and Explicit debug variants remain. ## Related Issues - Fixes #4514 - Fixes #4515 - Builds on merged #4503 and retains the #4508 publishing-drain behavior. ## Validation **Lease-election review correction (`a13afa641182c70983e16bbcfebf507e666a45a5`):** The correction is based on the existing upstream merge `43079e2b851fe338a4082066f8d14abfdafb6e32`. Three deterministic stale-verdict cases fail on the original source and pass with separate confirmation fencing. Six new cases cover the initial foreign-owner verdict, failed-CAS revalidation in both invocation orders, and fresh ownership loss through both read paths, including revalidation begun after another successful confirmation. | Target and configuration | Passed | Failed | Skipped | | --- | ---: | ---: | ---: | | net10.0 Debug | 120 | 0 | 0 | | net48 Debug | 120 | 0 | 0 | The selection is the full `Opc.Ua.Core.Tests.Redundancy` namespace, including all 38 `SharedStoreLeaseElectionTests` cases and the existing expiry, clock rollback, disposal, and overlapping-success regressions. New witnesses are `StaleForeignLeaseReadDoesNotRevokeNewerAcquisitionAsync`, `StaleFailedRevalidationDoesNotRevokeNewerAcquisitionAsync`, and `FreshForeignLeaseObservationRevokesLeadershipAsync`. They assert resulting leadership, exact transitions, independent progress, and the unchanged expiry boundary. Retaining the initial generation for the fallback read independently makes the fresh-loss-after-confirmation control fail; that mutation was removed before the final rebuilt runs. The successful scoped command is `dotnet test tests\Opc.Ua.Core.Tests\Opc.Ua.Core.Tests.csproj -c Debug --no-restore --filter "FullyQualifiedName~Opc.Ua.Core.Tests.Redundancy"`, with `CustomTestTarget` and `-f` matching each row and that framework's assets restored. Both changed C# files pass formatting and analyzers through informational severity on both frameworks. These are scoped results, not a full-solution test or measured coverage-delta claim. CI monitoring is paused for this feedback-only operation; no current-head CI success is claimed. **Latest CI contract correction (`1cd1f2047bb5b50c9bf300b1ff710b0b2e935a29`):** All four failing Server shard 23 jobs in [run 36120946740](https://github.com/OPCFoundation/UA-.NETStandard/actions/runs/36120946740) reported the same six cases. Two local replays reproduced all six failures. The source changes and documentation from `cd43b831a` establish both intentional contracts: independent bounded event compensation and surfaced provider errors. Only the two test files are corrected; production code and CI policy are unchanged. | Target and configuration | Passed | Failed | Skipped | | --- | ---: | ---: | ---: | | net48 Debug | 113 | 0 | 0 | | net10.0 Release, CI Coverlet settings | 113 | 0 | 0 | The selection contains `MonitoredItemManagerLifecycleTests`, `DirectoryObjectStateTests`, `DirectoryBrowserBoundedEnumerationTests`, and `EventCompensationReadinessHasFiniteDeadlineWithInfiniteSourceTimeoutAsync`. Both changed files pass formatting and analyzers through informational severity on both frameworks. Independent mutations restoring a non-cancellable compensation token and swallowing provider errors fail all four lifecycle cases and both browser cases, respectively. Both mutations were removed before the final passing runs and commit. The scoped command is `dotnet test tests\Opc.Ua.Server.Tests\Opc.Ua.Server.Tests.csproj` with `CustomTestTarget` and `-f` set to the selected framework, `--no-restore`, and `--filter "FullyQualifiedName~MonitoredItemManagerLifecycleTests|FullyQualifiedName~DirectoryObjectStateTests|FullyQualifiedName~DirectoryBrowserBoundedEnumerationTests|FullyQualifiedName~EventCompensationReadinessHasFiniteDeadlineWithInfiniteSourceTimeoutAsync"`. The modern run additionally uses `-c Release /p:CollectCoverage=true --collect "XPlat Code Coverage" --settings tests\coverlet.runsettings.xml`. These are scoped results, not a full-solution test or measured coverage-delta claim. Hosted checks for that commit were pending at publication. The fail-fast CI watch was stopped when the requested operation switched to review feedback; no current-head CI success is claimed. **Earlier master merge (`8b15ea6ef9fc2214ac8ec886f373bb3846dd6d6f`, base `b4136d8e9bab0df680f41d9ef985f519160534b1`):** The `TcpListenerChannel` conflict preserves both lifetime responsibilities: disposal clears the retained peer-certificate chain and unregisters the quota-scoped message-assembly scheduler. Both fields and all upstream deadline handling remain intact. The only additional manual source adjustment adds the parentheses required by the existing analyzer around the session-count fallback. Master advanced during validation. The initial transport merge `80303c434ce813cf75aa7fe54a6a32fe2aeafc30` was retained, and the newer base was merged normally in `8b15ea6ef9fc2214ac8ec886f373bb3846dd6d6f`, without rewriting history. The additional recovery, fluent-pipeline, and test-framework overlaps were inspected; the branch's cancellation, validation-error, and acknowledgement ownership fixes remain alongside the upstream changes. Full `UA.slnx` builds passed on net10.0 and net48 with **0 warnings and 0 errors**. The final scoped tests rebuilt against the committed tree `2074f8efcb61d8d9c6106e7d6f76ee941940e358`: | Target | Passed | Failed | Skipped | | --- | ---: | ---: | ---: | | net10.0 | 2,653 | 0 | 0 | | net48 | 2,529 | 0 | 7 | The net48 skips are existing platform exclusions: six RSA_DH_AesGcm nonce cases and one ClientWebSocket certificate-validation case requiring .NET 7 or later. The selection covers transport cleanup, reassembly budgets/deadlines/admission, peer-chain ownership, server session binding, fluent lifecycles and source generation, reconnect and keepalive, transfer acknowledgements, Publish queues, and JSON encoding. Modern-.NET WSS transport and real-server integration cases also pass. The resolved file passed solution-relative formatting and analyzer verification through informational severity on both frameworks. These are scoped test results, not unfiltered full-suite or measured-coverage claims. Hosted CI was not monitored or repaired as part of this merge-only operation. Selected local results below are overlapping validation runs, not distinct-test totals. **Earlier review-feedback batch (`cd43b831a8f11de3f7d65c3681712a61e3c669a5`):** The final net10.0 scoped selection passed **471 tests, with no failures or skips**. The final net48 solution-root selection passed **439 tests, with no failures and one unchanged platform skip** (`ValidateServerCertificateFallsBackToTlsPolicyErrors`, which requires newer ClientWebSocket certificate-validation support). Both full nonincremental solution builds completed with **0 warnings and 0 errors**, and all **40 changed C# files** passed informational-level formatting/analyzer verification. Both frameworks validated the identical final tracked diff. The focused matrix covers every one of the 20 current review threads, including real mid-frame WSS cancellation, stale admission contexts, directory continuation ownership, multi-element Browse/Translate failures, and the readiness drain race. The net10.0 solution-root test command has a separate pre-existing runner conflict: three unrelated AOT projects reject the VSTest target under Microsoft.Testing.Platform 2.4.0 and SDK 10. Per-project net10.0 results are recorded separately; no runner migration, test exclusion, assertion relaxation, or CI-policy change is included. These are bounded selections, not unfiltered full-suite or measured coverage claims. At publication of `cd43b831a`, hosted CI was not green: the `OPCFoundation.UA-.NETStandard` check reported `ACTION_REQUIRED`, AppVeyor reported `FAILURE`, and the two signed-build jobs were in progress. No CI jobs were rerun, cancelled, approved, or repaired as part of that feedback-only operation. **Classic transfer correction (`f425376fca045794122e19aaf9cafebc4a717d91`):** The preceding head's [macOS net10.0 shard 25/30](https://github.com/OPCFoundation/UA-.NETStandard/actions/runs/35980326299/job/107570512458) failed `TransferSubscriptionTest(DisconnectedRepublish).TransferSubscriptionAsync(True,False)` at the static-node count assertion: expected 10, received 20. The trace contains a successful, explicitly requested republish of retained sequence 1 and a fresh initial-value batch at sequence 3, not duplicate dispatch of one response. Controlled lost origin acknowledgements reproduced that incorrect test expectation on both the preceding PR head and exact master base `a2539cb53dd1666cef856f33812d7a8830d68909`. The maintainer explicitly authorized correcting this existing issue in the PR. Retained replay and requested initial values are independent, as specified by [OPC UA Part 4, TransferSubscriptions](https://reference.opcfoundation.org/specs/OPC-10000-4/5.14.7). The reason that the original hosted run retained its particular origin message remains unproved. The expanded full-suite run then exposed a **real production race**, not just a count assertion: Session queued acknowledgements for all transferred available messages before the asynchronous Republish responses arrived. Publish could delete those messages first, causing `BadMessageNotAvailable`. A gated real-Session/classic-engine regression failed both replay-enabled cases on the original implementation while both no-replay controls passed. The ownership fix passes all 24 combinations of empty/consecutive/unsorted-gapped queues, replay preference, sequential delivery, and a reentrant preference change. All 40 normal real-server transfer cases pass on both frameworks. Independent mutations suppressing actual replay and duplicating observed sequence records each fail the intended assertion; both mutations were removed. The full classic suite passes on both frameworks. Hosted CI was pending when that correction was published; the earlier failed hosted job was not rerun for a lucky pass. **Local net48 caveat:** the full Client run timed out in the pre-existing fake-clock test `ReconnectAsyncSwapsFaultedLeaseEntryAsync`, which does not exercise subscription transfer. Its unchanged isolated replay passed, but that does not resolve the full-suite timeout. No unrelated code or assertions were changed, and a clean full Client net48 run is not claimed. | Scope | net10.0 | net48 | | --- | --- | --- | | Full Client.Tests after transfer acknowledgement correction | 2,549 passed | 2,552 passed, 1 timeout failure noted above, 1 existing platform skip | | Full classic-subscription project after transfer correction | 57 passed, including all 40 normal transfer cases | 57 passed, including all 40 normal transfer cases | | Gated transferred-message acknowledgement ownership | 24 passed, included above | 24 passed, included above | | Saved-session consumer of the shared subscription helper | 40 passed | 16 passed, 24 existing unsupported-security-policy skips | | Audit regression matrix | 2,339 passed | 1,934 passed, 34 existing skips | | Full Client.Tests after review follow-ups | 2,520 passed | Focused selections only | | Full Client.Tests after CI recovery correction | 2,523 passed, CI coverage collector | 2,527 passed, 1 existing platform skip | | Latest merged-head recovery/redundancy selection | 139 passed | 139 passed | | Full Client.Tests after merged-head net48 correction | Focused selection above | 2,529 passed, 1 existing platform skip | | Lifecycle and actual-dimension regressions | 146 passed | 146 passed | | Focused recovery follow-ups | 7 passed | 7 passed | | WSS-related fixtures | 93 passed | Not applicable | | CI History integration corrections | 28 passed | 28 passed | | CI alias, dispatcher, and explicit-atomic controls | 63 passed | 63 passed | | Full WoT sample project with CI coverage collector | 29 passed | Not applicable | | Trust-list effects and count-only summary contract | 22 passed | 22 passed | | Virtual-node feedback, node-manager, dispatcher, and lifecycle selections | 661 passed, 63 existing not-executed cases; all 48 virtual-node cases passed | 661 passed, 63 existing not-executed cases; all 48 virtual-node cases passed | | WSS wire-handle exhaustion, late replies, and Cancel translation | 9 passed | Not applicable | | Synchronous node-manager disposal and related service/lifecycle suites | 1,037 passed, 64 not-executed cases from existing fixture assumptions; all 93 sampling/disposal cases passed | 1,037 passed, 64 not-executed cases from existing fixture assumptions; all 93 sampling/disposal cases passed | - Full solution builds passed on net10.0 and net48 during the audit work. The subsequent full non-incremental net10.0 build also completed with zero warnings/errors. - Modified source/test formatting and analyzer checks passed through informational severity. - Original composed recovery regressions passed repeated runs on both frameworks. Independent mutations removing Publish cancellation, bypassing the recreate drain call, or ignoring ready-gate cancellation each failed the intended assertion; all mutations were restored before passing runs. - The latest recovery correction passed 100 executions of each original restoration-deadline scenario with unchanged assertions. Restoring premature update admission independently made both strengthened handoff cases fail; the correct retention behavior was restored afterward. - Startup-event and cancellation-delivery timing probes reproduced the subsequent net48 failures, then passed 100 executions of each of the three scenarios with the fixes. All timing probes and repetition were removed. The permanent timeout-order regression also went RED on the original cancellation cleanup without sleeps or scheduling amplification. - Final post-format recovery/subscription/engine checks passed all 151 selected cases on net10.0. The first local full net48 attempt aborted in `clr.dll` with native access violation `0xc0000005`; an unchanged-assembly full diagnostic rerun passed as recorded above. That native host crash has not been attributed or claimed fixed. - A minimal replay with the same CodeQL CLI and query-pack versions reproduced the custom-count alert and confirmed that the standard span-length projection is clean while direct sensitive-value and raw-identifier logging controls remain flagged. The source was already logging only a count; no identifier or certificate disclosure was established at the reported location. - The virtual-node feedback regressions first reproduced five error-preservation/cache failures; expanded service-dispatch cases exposed two additional whole-batch failures before the operation-boundary fixes. The final selections pass on both frameworks. Genuine misses remain cached, successful results remain reusable, failed validation allows same-cache retry, and request cancellation still stops the batch. - Three deterministic synchronous-manager disposal regressions reproduced early resource release and lifecycle calls reaching a manager during disposal. The fix passes direct and adapted calls, a sampling-worker join after the last operation exits, reentrant shutdown, cancellation and failure paths, repeated disposal, and exact rejection across 39 service/lifecycle entry points both during drain and after cleanup. The original outside-node-lock disposal regression remains and now requires the worker to observe closed admission. - Prior-head CI on `c985f8bded77a60449d9cd381c8a004d8590ccf6` completed with **168 passing checks, one expected release-package-publication skip, and no failures or pending checks**. All test shards, signed builds, container builds, the coverage check, and the CodeQL PR check passed. Hosted CodeQL analysis `1829262912` completed successfully and marks count-flow alert 385 fixed. This is evidence for that head, not a claim that subsequent review follow-ups have completed hosted CI. ### Session-test diagnostic memory Complete browse listings are streamed to unique NUnit attachments rather than retained in inline NUnit/VSTest output. Detached regression-test results are cleaned up without deleting runner-collected attachments. - Two covered session-suite runs each completed with 782 passed and 379 existing skips. Peak working set fell from 4.86 GiB to 2.44-2.78 GiB. - TRX output shrank from 303.1 MiB to approximately 40.7 MiB, retaining all 46 complete browse attachments. - Seven output regressions cover exact content, empty/single/large listings, disabled/enabled output, bounded inline summaries, and unique filenames. The separate Azure build-18452 VM deprovisioning cause remains unproved without provider logs. The memory measurements do not establish its cause. Unfiltered local `UA.slnx` tests, including the long stress/soak suites, have not been run on both frameworks. A local coverage delta was not collected; builds and focused tests are not substitutes for those claims. ## Checklist - [ ] I have signed the [CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf) and read the [CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md) doc. - [ ] I have added tests that prove my fix is effective or that my feature works and increased code coverage. - [x] I have added all necessary documentation. - [x] I have verified that my changes do not introduce (new) build or analyzer warnings. - [ ] I ran **all** tests locally using the **UA.slnx** solution against at least .net **framework** and .net **10**, and all passed. - [ ] All checks on the latest review-follow-up head have completed successfully; prior-head CI passed as recorded above. - [x] I have addressed **all** PR feedback received. Tests have been added and pass as recorded above; the hosted coverage check passed on the prior head identified above. The combined tests/coverage-increase checkbox remains open because no local coverage delta was measured. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 68f26810-22b8-476f-abcd-0a8c82d6e208
537 lines
57 KiB
Markdown
537 lines
57 KiB
Markdown
# Developer Guide
|
||
|
||
This guide is the starting point for contributing to the OPC UA .NET Standard stack. It covers what to install, how to build and test, the coding standards ("dos and don'ts"), and task-oriented "how to" recipes (starting with how to add logging). It links out to the topic-specific documents in [docs/README.md](README.md) rather than repeating them.
|
||
|
||
If you are new here, read the sections in order: [Prerequisites](#prerequisites) → [Repository layout](#repository-layout) → [Building](#building) → [Running tests](#running-tests) → [Coding standards](#coding-standards-dos-and-donts). The [How-to guides](#how-to-guides) and [Packages, platform support, and versioning](#packages-platform-support-and-versioning) sections are reference material you can jump to as needed.
|
||
|
||
## Prerequisites
|
||
|
||
- **.NET SDK 10.0** — the whole repository builds and restores with the [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0). Older SDKs are not supported for building `main`. The class libraries still *target* older frameworks (see [Packages, platform support, and versioning](#packages-platform-support-and-versioning)), but you build them with the .NET 10 SDK.
|
||
- **An IDE (optional but recommended)** — Visual Studio 2026, Visual Studio Code with the C# Dev Kit, or JetBrains Rider. Everything can also be done from the command line with `dotnet`.
|
||
- **git** — to clone and to create feature branches.
|
||
- **Docker Desktop (optional)** — only needed to run the containerized reference server; see [ContainerReferenceServer.md](ContainerReferenceServer.md).
|
||
|
||
The C# language version is pinned (`LangVersion` 14) and analyzer/style rules are enforced by the build, so no extra tooling install is required to get the same diagnostics locally that CI produces.
|
||
|
||
## Repository layout
|
||
|
||
| Path | Contents |
|
||
| --- | --- |
|
||
| `src/` | The core stack and higher-level libraries: `Opc.Ua.Types`, `Opc.Ua.Core*`, `Opc.Ua.Client`, `Opc.Ua.Server`, `Opc.Ua.Configuration`, `Opc.Ua.PubSub` (+ transports), the GDS / DI / LDS / WoT libraries, and the `Opc.Ua.Redundancy*` family. |
|
||
| `samples/` | Reference and sample apps: `ConsoleReferenceServer`, `ConsoleReferenceClient`, `Quickstarts.Servers`, the `Minimal*` / `PumpDeviceIntegrationServer` NativeAOT samples, `Redundant*`, etc. |
|
||
| `tests/` | Unit and integration test projects, mirroring the library structure, plus shared test frameworks. |
|
||
| `tools/` | Source generators, migration analyzers, and the installable `Opc.Ua.Mcp` tool. Each analyzer and generator has a build project and — for the source generators — a `*.Pack` project that packages it under a Roslyn-versioned analyzer folder. |
|
||
| `docs/` | This documentation set (indexed by [docs/README.md](README.md)). |
|
||
| `fuzzing/` | SharpFuzz / libFuzzer fuzz targets (see [Fuzzing.md](../fuzzing/Fuzzing.md)). |
|
||
|
||
Central build configuration lives at the repository root and is imported by every project:
|
||
|
||
- `UA.slnx` — the solution containing all projects.
|
||
- `Directory.Build.props` / `Directory.Build.targets` — global MSBuild properties and targets.
|
||
- `Directory.Packages.props` — [Central Package Management](https://learn.microsoft.com/nuget/consume-packages/central-package-management): every NuGet version is declared here.
|
||
- `common.props` / `targets.props` — shared properties, analyzer settings, and the target-framework matrix.
|
||
- `.editorconfig` — the authoritative code-style and analyzer-severity rules (enforced at build time).
|
||
|
||
## Building
|
||
|
||
From the repository root:
|
||
|
||
```bash
|
||
dotnet restore UA.slnx
|
||
dotnet build UA.slnx
|
||
```
|
||
|
||
Notes:
|
||
|
||
- **Warnings are errors.** `TreatWarningsAsErrors` is enabled, so compiler (`CSxxxx`) and Roslynator (`RCSxxxx`) diagnostics fail the build. Microsoft Code Analysis (`CAxxxx`) diagnostics are emitted as non-fatal warnings unless a rule is promoted to error in `.editorconfig`. Fix all of them before opening a pull request.
|
||
- **Building a single target framework.** By default the libraries multi-target the whole matrix (see [Packages, platform support, and versioning](#packages-platform-support-and-versioning)). To restrict a local build to one framework, pass `-p:CustomTargetFrameworks`, for example:
|
||
|
||
```bash
|
||
dotnet build src/Opc.Ua.Core/Opc.Ua.Core.csproj -f net10.0 -p:CustomTargetFrameworks=net10.0
|
||
```
|
||
|
||
- **Offline / restricted networks.** `NuGetAudit` is enabled and fails the build with `NU1900` when it cannot reach the audit service. If you build offline, pass `-p:NuGetAudit=false`.
|
||
- **Source generators are consumed as project references.** Projects that use the in-repo generators reference `tools/Opc.Ua.SourceGeneration[.Stack]` with `OutputItemType=Analyzer`. MSBuild only hands the compiler the generator assembly itself, so `Directory.Build.targets` adds the generator's runtime closure (its output directory, minus the Roslyn host assemblies) as `Analyzer` items — the same payload the generator NuGet packages ship under `analyzers/dotnet/<roslyn>/cs`. Without it the generators cannot resolve their dependencies and fail to initialise with `CS8784`.
|
||
- **Analyzers and generators are shipped under a Roslyn-versioned analyzer folder.** `roslyn.props` pins the Roslyn API version, and each generator has a `*.Pack` project that ships it under `analyzers/dotnet/roslyn<major>.<minor>/cs`. See [Repository layout](#repository-layout) and the [support matrix](#supported-analyzer-and-source-generator-hosts). Because the repository's own projects consume that same build, **building this repository requires a Roslyn 5.x host** (the .NET 10 SDK or Visual Studio 2026).
|
||
|
||
## Running tests
|
||
|
||
Run the whole suite from the solution:
|
||
|
||
```bash
|
||
dotnet test UA.slnx
|
||
```
|
||
|
||
Conventions and requirements:
|
||
|
||
- **Frameworks.** Test projects use either **NUnit** (with `Assert.That` assertions and **Moq** for mocking) or **TUnit** (with its own assertions and mock helpers). Do not mix the two in one project, and do not use the classic NUnit asserts (`Assert.AreEqual`, …).
|
||
- **Coverage.** Coverage is measured with **Coverlet** and must not regress; every non-application, non-test project should stay at or above **80 %**. Two gates enforce this in CI — see [Continuous integration](#continuous-integration).
|
||
- **Integration tests.** Client/server and pub/sub features need integration tests as well as unit tests. A feature library's integration tests normally live with its unit tests in `<Component>.Tests`, for example `Opc.Ua.Robotics.Tests`, and every test project name ends in `.Tests`. Split integration tests into a separate project only when they run long, destabilise the unit tests, or the suite needs further division. Keep them deterministic: allocate a free port per fixture rather than hard-coding one, wait on the actual signal instead of using `Thread.Sleep` as a synchronisation primitive, and dispose every session, subscription and server in teardown including on failure. A flaky integration test is worse than none.
|
||
- **Test output.** Keep successful-test output compact. NUnit and the test runner retain captured output and create additional copies when forwarding and serializing it to TRX; repeated exhaustive dumps can consume gigabytes of memory, inflate results artifacts, and stall the runner's shared socket (issue #4213). For small failure-only diagnostics, buffer the dump and emit it only when the test does not pass; see `EncoderCommon.TestOutput` in [`tests/Opc.Ua.Core.TestFramework/EncoderCommon.cs`](../tests/Opc.Ua.Core.TestFramework/EncoderCommon.cs). Stream large dumps to files and register them with `TestContext.AddTestAttachment` instead of building a large string or writing every line to `TestContext.Out`. `CommonTestWorkers.BrowseFullAddressSpaceWorkerAsync(..., outputResult: true)` follows this pattern: the full listing is a test-result attachment, while inline output contains only the reference count. Keep attachment files until the runner has collected them.
|
||
- **Certificate leak diagnostics.** Set `OPCUA_CERTIFICATE_LEAK_TRACKING=1` before starting a
|
||
test process to capture allocation stacks for `Certificate` handles that are not explicitly
|
||
disposed. The assembly-level leak detector includes live and unreachable outstanding handles,
|
||
their allocation stacks, and the NUnit fixture where available. Tracking is enabled by default
|
||
in Debug builds and opt-in in Release because stack capture adds per-certificate overhead. The
|
||
equivalent AppContext switch is `Opc.Ua.Security.Certificates.CertificateLeakTracking`; set it
|
||
before the first `Certificate` is created. For example, in PowerShell:
|
||
|
||
```powershell
|
||
$env:OPCUA_CERTIFICATE_LEAK_TRACKING = '1'
|
||
dotnet test `
|
||
tests\Opc.Ua.Sessions.Tests\Opc.Ua.Sessions.Tests.csproj `
|
||
-f net10.0 `
|
||
-p:CustomTestTarget=net10.0
|
||
```
|
||
- **Before a pull request** the `UA.slnx` suite must pass on at least **.NET Framework 4.8** and **.NET 10.0**.
|
||
- **Testing a specific target framework.** The libraries multi-target, but the test executables run on one framework at a time. To run the suite against a non-default framework, set `CustomTestTarget` (supported values: `netstandard2.0`, `netstandard2.1`, `net472`, `net48`, `net8.0`, `net9.0`, `net10.0`). The batch file [`tests/customtest.bat`](../tests/customtest.bat) cleans, restores, and runs the tests for a chosen target; in Visual Studio, uncomment and set the `CustomTestTarget` property in [`targets.props`](../targets.props). A clean build for the target is recommended when switching.
|
||
- **CI matrix.** The pull-request gate runs the test suite on **net48** and **net10.0**, and compiles the solution for *every* supported target framework; the remaining test matrices (Debug, .NET 9/8, .NET Framework 4.7.2, netstandard) run in scheduled or manual CI. Fix all failing, flaky, and CodeQL findings in the pipelines. See [Continuous integration](#continuous-integration).
|
||
|
||
Channel recovery regressions use `ManagedSessionReconnectTests` in the client
|
||
test project and `ClientChannelManagerManagedTests` / `ReconnectDeadlineTests`
|
||
in the core test project. The composed fixture scripts an in-process transport
|
||
while retaining the real session, V2 publishing workers and channel ready gate.
|
||
Use phase signals and the injected clock rather than a server-process kill or
|
||
sleeps. Keep every phase wait bounded. When checking the recreate/drain wiring,
|
||
disable the managed-session channel deadline so outer takeover cannot mask a
|
||
broken drain, and verify the parked Publish attempt has unwound before allowing
|
||
replacement-session creation to finish.
|
||
|
||
## Coding standards (dos and don'ts)
|
||
|
||
All rules apply to new code and to existing code you touch. The `.editorconfig` is authoritative and enforced at build time; the highlights below are the ones most often missed.
|
||
|
||
**Formatting and style**
|
||
|
||
- Add the OPC Foundation MIT license header to every new source file.
|
||
- 4-space indentation, max line length 120, CRLF line endings, UTF-8, final newline, no trailing whitespace.
|
||
- Allman braces; always specify access modifiers explicitly; member order is constructors → properties/events → methods → fields, each `public` → `protected` → `internal` → `private`.
|
||
- Do **not** use `#region`/`#endregion` or comment-only section dividers. Do **not** add `#nullable enable` to a file when the project already sets `<Nullable>enable</Nullable>`.
|
||
- Put every XML-doc `<summary>` text on its own line (never a single-line `/// <summary> … </summary>`).
|
||
- Follow standard C# naming; no underscores in method or test-method names (tests use PascalCase).
|
||
|
||
**API and language**
|
||
|
||
- **Async only.** New code uses `async`/`await` (TAP). Do not add APM or sync-over-async (`.Result`, `.Wait()`, `GetAwaiter().GetResult()`) unless explicitly requested.
|
||
- **No `object` in public API** (except when overriding `Equals`). For OPC UA values use `Variant`.
|
||
- **`INullable` types** must not be wrapped in `System.Nullable<T>` (`T?`); use `.IsNull` / `.Null` instead. On struct types prefer `TryGet`/`TryGetValue` over casting; never use `Variant.AsBoxedValue` or `IUnion.Value`.
|
||
- Prefer `ArrayOf<T>` over read-only collection types / `IReadOnlyList<T>` / arrays in new public API; prefer `ByteString` over `byte[]`; prefer `Span<byte>`/`ReadOnlySpan<byte>` over `byte[]`.
|
||
- Do not use `[Obsolete]` API (outside test code) and do not add API that is not NativeAOT-compatible.
|
||
- Maintain backward compatibility with 1.5.378; mark replaced API `[Obsolete]` rather than removing it.
|
||
|
||
**Concurrency**
|
||
|
||
- Never expose locks in any API surface. For a synchronous lock use `System.Threading.Lock` (a polyfill is provided for older TFMs) — never `private readonly object m_lock = new()`. Prefer `SemaphoreSlim` where async coordination is needed.
|
||
|
||
**Architecture**
|
||
|
||
- Make non-abstract public classes `sealed` by default; prefer a provider model with injectable providers over inheritance.
|
||
- Wire new functionality into the dependency-injection infrastructure (with a direct "construct it yourself" fallback) and expose it through the fluent API where possible.
|
||
- Reuse the existing base services (telemetry, file system, certificate/secret stores, state machines, sessions, source generators, …) instead of re-implementing them.
|
||
|
||
**Security**
|
||
|
||
- Never hardcode credentials, certificates, or secrets. Manage certificates through the certificate store system and secrets through the secret store (see [CertificateManager.md](CertificateManager.md) and [Certificates.md](Certificates.md)).
|
||
- Use only SHA-2 or stronger hash algorithms; use the audit and redaction APIs for sensitive data.
|
||
|
||
**Logging** — use source-generated logging; never call `ILogger.LogInformation/LogError/…` directly. See [Add a log message (source-generated)](#add-a-log-message-source-generated).
|
||
|
||
## How-to guides
|
||
|
||
### Add a log message (source-generated)
|
||
|
||
The stack uses [`LoggerMessageAttribute`](https://learn.microsoft.com/dotnet/core/extensions/logger-message-generator) source-generated logging **everywhere**. It avoids boxing value-type arguments, caches the message formatter, and emits an `IsEnabled` check so a disabled level costs nothing. Direct `ILogger.LogInformation/LogError/…` calls are not allowed. The runtime/observability side (how the `ILogger` is created from `ITelemetryContext`) is documented in [Diagnostics.md](Diagnostics.md#high-speed-logging-and-source-generators); this section is the authoring recipe.
|
||
|
||
**Recipe**
|
||
|
||
1. **Get a logger.** Obtain an `ILogger` from the ambient `ITelemetryContext` (`telemetry.CreateLogger<T>()`); most types already hold one in an `m_logger` field.
|
||
2. **Find or create the log class.** Each file that logs has, at its end, an `internal static partial class <PrimaryClass>Log` holding `[LoggerMessage]` **extension methods on `ILogger`**. Add your message there. If several closely-related files emit the *same* messages, use one shared `<Area>Log` class instead of duplicating (for example the encoders/decoders in `Opc.Ua.Types` share `EncodingLog`).
|
||
3. **Reserve an event id.** Each project has one `internal static class <AssemblyToken>EventIds` at its root (see [Event-id convention](#event-id-convention)). Use the existing per-class offset.
|
||
4. **Declare the message.** Add a partial method with `[LoggerMessage(EventId = <AssemblyToken>EventIds.<Class> + <index>, Level = LogLevel.<Level>, Message = "…")]` (see [Log class convention](#log-class-convention)).
|
||
5. **Call it.** Replace the old `logger.LogXxx(...)` call with `logger.<MethodName>(args)`.
|
||
|
||
#### Event-id convention
|
||
|
||
Each project owns exactly one event-id class, named `<AssemblyToken>EventIds`, in `namespace Opc.Ua`, in a file `EventIds.cs` at the project root. `<AssemblyToken>` is the assembly name with the `Opc.Ua.` prefix removed and dots dropped — for example `Opc.Ua.Core` → `CoreEventIds`, `Opc.Ua.Core.Types` → `CoreTypesEventIds`, `Opc.Ua.Client` → `ClientEventIds`.
|
||
|
||
The token prefix is required because the stack uses `InternalsVisibleTo`: two `internal` classes with the same name in the same namespace collide across an IVT boundary (`CS0436`). The class holds one `public const int` offset per log class. Offsets are assigned in class-alphabetical order starting at 0; each block reserves at least five spare slots for future messages and is then rounded up to the next multiple of ten, so ids stay documented and managed in one place. Every log method sets `EventId = <AssemblyToken>EventIds.<Class> + <zero-based message index within that class>`.
|
||
|
||
##### Narrow exception: retained EventSource-compatibility ids
|
||
|
||
The four legacy `System.Diagnostics.Tracing.EventSource` providers (`OPC-UA-Core`, `OPC-UA-Client`, `OPC-UA-Server`, `Opc.Ua.ChannelManager`) were removed and replaced with `[LoggerMessage]` equivalents. Their compatibility log methods are a deliberate, narrow exception to the convention above: each keeps the exact numeric id, event name, level, message template, and structured fields the corresponding ETW event had, so consumers can preserve event identity when they migrate from ETW to `ILogger`. Concretely:
|
||
|
||
- The compatibility log class uses the **old provider name as its `ILogger` category** (e.g. `"OPC-UA-Core"`, `"Opc.Ua.ChannelManager"`) instead of the typed, per-class category used elsewhere in the project.
|
||
- `EventId` resolves to the **literal legacy numeric id** (e.g. `10` for `OPC-UA-Core`'s former `ServiceCallStart`) rather than a normal per-class offset. Keep these values in the affected project's `EventIds.cs`; compatibility ids are scoped to their own logger category, so they may intentionally overlap ordinary per-assembly values.
|
||
- Every compatibility method sets `EventName` explicitly (`[LoggerMessage(EventId = 10, EventName = "ServiceCallStart", Level = LogLevel.Trace, Message = "...")]`) so `EventId.Name` matches the original ETW event name exactly.
|
||
- ETW-only metadata (provider GUID, `Task`, `Keywords`, manifest) is **not** retained because there is no `ILogger` equivalent.
|
||
- Do **not** use this pattern for new log messages. It exists only to preserve the event identities that previously shipped through the four EventSource providers; see [Diagnostics.md](Diagnostics.md#high-speed-logging-and-source-generators) and [migrate/2.0.x/telemetry.md](migrate/2.0.x/telemetry.md) for the full removal/compatibility mapping.
|
||
|
||
#### Log class convention
|
||
|
||
- **One log class per file**, named `<PrimaryClass>Log`, `internal static partial`, appended at the end of the file inside the same namespace.
|
||
- Methods are **extension methods on `ILogger`** (`public static partial void <Name>(this ILogger logger, …)`) so call sites read naturally as `logger.<Name>(…)`.
|
||
- Identical `this ILogger` overloads (same name and parameter types) declared in more than one class of the same namespace collide (`CS0121`) — deduplicate them into a single shared `<Area>Log` class. Overloads that differ by name or by parameter type are fine.
|
||
|
||
#### Message, level, and parameter rules
|
||
|
||
- **Message text** is exact and static; use named placeholders (`{ChannelId}`) that match a parameter of the same name. Never interpolate (`$"…"`). An `Exception` argument is detected by its type and does not need a placeholder.
|
||
- **Parameter types** must match the real argument type. Do **not** use `object`/`object?`, and do **not** call `.ToString()` on an argument (type the parameter instead, e.g. an enum or `int`); an unnecessary `.ToString()` trips `RCS1097`/`CA1305`. Declare a parameter nullable (`string?`, `Uri?`, `Exception?`) only when the argument can actually be null, otherwise the compiler reports `CS8604`.
|
||
- **Guard only expensive arguments.** If a call passes an expensive computed argument (`string.Join(...)`, a LINQ projection, `.ToString()` on a complex object) wrap it in `if (logger.IsEnabled(<level>))`; source generation does not suppress eager evaluation of the *arguments*, and `CA1873` flags it. Do **not** guard cheap arguments (locals, fields, ids) — over-guarding trips `RCS1006`/`RCS1061`. A guard must never gate an expression that has an observable side effect.
|
||
- **Dynamic levels stay hand-written.** `[LoggerMessage]` needs a compile-time `Level`. A call whose level is only known at runtime keeps the structured `logger.Log(logLevel, "{Template}", args)` form wrapped in `if (logger.IsEnabled(logLevel))`. These are the only remaining direct `ILogger.Log` calls.
|
||
- **Shared/linked source files** that are `<Compile Include>`-d into more than one project (for example a sample file linked into a test project) cannot reference another assembly's `<AssemblyToken>EventIds` class — give their log class literal `EventId` integers in a high, dedicated range instead.
|
||
- **Duplicate generator on netstandard.** A project that also references an R9 package (`Microsoft.Extensions.Http.Resilience`, `.Compliance`, `.Telemetry`, …) gets the `Microsoft.Gen.Logging` generator in addition to the in-box one; on `netstandard` both implement every partial method (`CS0757`). The repo's `Directory.Build.targets` removes the R9 analyzer on `netstandard` only — no per-project action is needed.
|
||
|
||
**Worked example**
|
||
|
||
```csharp
|
||
// EventIds.cs (project root) — the assembly-token prefix avoids CS0436 across
|
||
// InternalsVisibleTo boundaries.
|
||
namespace Opc.Ua
|
||
{
|
||
internal static class TypesEventIds
|
||
{
|
||
public const int Encoding = 20; // shared codec block (reserves 20)
|
||
public const int Matrix = 50; // per-file block (reserves 10)
|
||
}
|
||
}
|
||
|
||
// end of Matrix.cs
|
||
internal static partial class MatrixLog
|
||
{
|
||
[LoggerMessage(EventId = TypesEventIds.Matrix + 0, Level = LogLevel.Debug,
|
||
Message = "ReadArray read dimensions[{Index}] = {Dimensions}. Matrix will have 0 elements.")]
|
||
public static partial void ReadArrayZeroDimension(this ILogger logger, int index, int[] dimensions);
|
||
}
|
||
|
||
// call site
|
||
logger.ReadArrayZeroDimension(index, dimensions);
|
||
```
|
||
|
||
**Checklist**
|
||
|
||
- [ ] Message text and level are unchanged from the original call (behavior-preserving).
|
||
- [ ] Placeholders are named and match parameter names; no interpolation.
|
||
- [ ] Parameter types match the arguments; nullable only where needed; no `object`.
|
||
- [ ] Expensive arguments are guarded with `IsEnabled`; cheap ones are not.
|
||
- [ ] `EventId` uses the project's `<AssemblyToken>EventIds` offset (or a literal range for a shared/linked file, or a literal legacy id with an explicit `EventName` for a retained EventSource-compatibility message — see [the narrow exception](#narrow-exception-retained-eventsource-compatibility-ids)).
|
||
- [ ] When testing with a mocked `ILogger`, stub `IsEnabled(...) => true` and match on `EventId.Name`, not the (empty) source-generated state `ToString()`.
|
||
|
||
### Other common tasks
|
||
|
||
- **Add a new feature** — implement it in the right library, add unit and (for client/server/pubsub) integration tests, update or add a doc under `docs/`, and keep backward compatibility (see [Coding standards](#coding-standards-dos-and-donts)).
|
||
- **Add a document** — put it in `docs/` and link it from [docs/README.md](README.md).
|
||
- **Add a dependency** — declare the version in `Directory.Packages.props` (Central Package Management), prefer AOT/trimmable and permissively licensed packages, and get maintainer approval first.
|
||
- **Certificates and secrets** — see [Certificates.md](Certificates.md) and [CertificateManager.md](CertificateManager.md).
|
||
- **Source-generated node managers / data types** — see [NodeManagers.md](NodeManagers.md#source-generated-node-managers) and [SourceGeneratedDataTypes.md](SourceGeneratedDataTypes.md).
|
||
- **Server namespace metadata / history advertisement** — see [NodeManagers.md](NodeManagers.md#server-address-space-metadata).
|
||
- **Dependency injection** — see [DependencyInjection.md](DependencyInjection.md).
|
||
- **NativeAOT** — see [NativeAoT.md](NativeAoT.md).
|
||
|
||
## Packages, platform support, and versioning
|
||
|
||
### Released packages
|
||
|
||
The following NuGet packages are released on a monthly cadence (with hot fixes for security issues). The `OPCFoundation` prefix is reserved, and the assemblies and packages are signed by the OPC Foundation.
|
||
|
||
- [OPCFoundation.NetStandard.Opc.Ua](https://www.nuget.org/packages/OPCFoundation.NetStandard.Opc.Ua/) — a convenience meta-package that pulls in everything except PubSub. Prefer referencing the individual packages below to reduce your dependency surface.
|
||
- [OPCFoundation.NetStandard.Opc.Ua.Types](https://www.nuget.org/packages/OPCFoundation.NetStandard.Opc.Ua.Types/)
|
||
- [OPCFoundation.NetStandard.Opc.Ua.Core.Types](https://www.nuget.org/packages/OPCFoundation.NetStandard.Opc.Ua.Core.Types/) — the generated OPC UA NodeSet models and state classes.
|
||
- [OPCFoundation.NetStandard.Opc.Ua.Core](https://www.nuget.org/packages/OPCFoundation.NetStandard.Opc.Ua.Core/) and [OPCFoundation.NetStandard.Opc.Ua.Security.Certificates](https://www.nuget.org/packages/OPCFoundation.NetStandard.Opc.Ua.Security.Certificates/) — required by both client and server projects.
|
||
- [OPCFoundation.NetStandard.Opc.Ua.Configuration](https://www.nuget.org/packages/OPCFoundation.NetStandard.Opc.Ua.Configuration/) — configure a UA application from file or with the fluent API.
|
||
- [OPCFoundation.NetStandard.Opc.Ua.Server](https://www.nuget.org/packages/OPCFoundation.NetStandard.Opc.Ua.Server/) — build a UA server.
|
||
- [OPCFoundation.NetStandard.Opc.Ua.Client](https://www.nuget.org/packages/OPCFoundation.NetStandard.Opc.Ua.Client/) and [OPCFoundation.NetStandard.Opc.Ua.Client.ComplexTypes](https://www.nuget.org/packages/OPCFoundation.NetStandard.Opc.Ua.Client.ComplexTypes/) — build a client; the complex-type library adds support for complex types.
|
||
- [OPCFoundation.NetStandard.Opc.Ua.Bindings.Https](https://www.nuget.org/packages/OPCFoundation.NetStandard.Opc.Ua.Bindings.Https/) — optional `opc.https` transport.
|
||
- [OPCFoundation.NetStandard.Opc.Ua.PubSub](https://www.nuget.org/packages/OPCFoundation.NetStandard.Opc.Ua.PubSub/) (Beta) — publisher/subscriber model.
|
||
|
||
For improved source-level debugging, symbol packages for non-`.Debug` package IDs are published on nuget.org in `snupkg` format. `Debug`-compiled packages are retained with a `.Debug` suffix on GitHub Packages but are not published to nuget.org.
|
||
|
||
In-development previews are published **only** to the [GitHub Packages feed](https://nuget.pkg.github.com/OPCFoundation/index.json) — nothing in this repository pushes a preview to nuget.org. nuget.org receives a version only through the manually approved [promotion](ReleaseProcess.md#approved-promotion) of a stable candidate built from a `release/<major>.<minor>` branch, which is where the `2.0.0-preview.N` packages currently on nuget.org came from (the earlier `release/2.0.0` line). To consume those, use `2.0.0-preview.*` to float to the latest published `2.0.0-preview.N` release, pass `--prerelease` to `dotnet add package`, or
|
||
select *Include prerelease* in Visual Studio. No additional package source or
|
||
credentials are required for nuget.org; the GitHub Packages feed needs a classic PAT with `read:packages`.
|
||
|
||
The full set of packages the preview pipeline produces is pinned in [`.azurepipelines/expected-packages.txt`](../.azurepipelines/expected-packages.txt). `.azurepipelines/validate-source-generator-packages.ps1` fails the build when the packed output does not match it, so adding, removing or renaming a shipped package has to be done deliberately in the same pull request. That script also validates the analyzer packages: their `analyzers/dotnet/roslyn<major>.<minor>/cs` layout, that they carry their runtime closure privately, that the model generator's auto-imported `build/<PackageId>.props` is named after the package id, and — end to end — that a standalone project consuming the packed generator with a NodeSet actually gets code generated.
|
||
|
||
### Supported target frameworks
|
||
|
||
The class libraries currently target:
|
||
|
||
1. .NET Standard 2.0 (`Opc.Ua.Types` only)
|
||
2. .NET Standard 2.1
|
||
3. .NET Framework 4.7.2 (limited support)
|
||
4. .NET Framework 4.8
|
||
5. .NET 8.0
|
||
6. .NET 9.0
|
||
7. .NET 10.0
|
||
|
||
The pull-request gate *compiles* every one of these targets, but only runs the test suite on (4) and (7) to keep the feedback loop short; the remaining test matrices are covered by scheduled or manual CI. See [Running tests](#running-tests) for how to build and test a specific framework locally with `CustomTestTarget` / `tests/customtest.bat`, and [Continuous integration](#continuous-integration) for how the matrices are split.
|
||
|
||
### Supported analyzer and source generator hosts
|
||
|
||
The analyzer and source generator packages ship under `analyzers/dotnet/roslyn<major>.<minor>/cs`. The .NET SDK loads the highest folder its compiler supports and **ignores** folders above it, so an older host cleanly skips the analyzer instead of loading it and failing at generator-initialization time.
|
||
|
||
| Roslyn API | Package folder | Minimum host |
|
||
| --- | --- | --- |
|
||
| 4.14 | `analyzers/dotnet/roslyn4.14/cs` | Visual Studio 2022 17.14 / .NET 9 SDK |
|
||
| 5.0 | `analyzers/dotnet/roslyn5.0/cs` | Visual Studio 2026 18.0 / .NET 10 SDK |
|
||
|
||
The version is declared once in `roslyn.props`.
|
||
|
||
> **Adding a band below 4.14 is not just another entry in that file.** The analyzer closure — the generator, `Opc.Ua.SourceGeneration.Core` **and** `Opc.Ua.Types` — must bind against the Roslyn host's own `System.Collections.Immutable` and `System.Reflection.Metadata`. .NET satisfies a reference from a *higher* assembly version but never from a lower one, and those assemblies are supplied by the compiler, so the closure must reference the lowest version across every supported band and must never ship a copy of its own. Roslyn 4.14 and 5.0 both depend on 9.0.0, which is why `$(RoslynRuntimeVersion)` in `roslyn.props` drives the central pin and one build of the non-Roslyn closure serves both bands. Going lower — Roslyn 4.8 wants 7.x — would mean building that whole closure, `Opc.Ua.Types` included, a second time.
|
||
>
|
||
> Get it wrong and the failure is silent: the generator is skipped (`CS9057`), fails to load (`CS8032`) or throws `MissingMethodException` while initializing (`CS8784`) — all *warnings*, so the consumer just gets no generated code. `validate-source-generator-packages.ps1` therefore refuses any package that ships `Microsoft.CodeAnalysis*`, `System.Collections.Immutable` or `System.Reflection.Metadata`, and runs the packed down-level payload through a real compiler of that band.
|
||
|
||
### Versioning
|
||
|
||
From **2.0** onward, package versions are produced by [Nerdbank.GitVersioning](https://github.com/dotnet/Nerdbank.GitVersioning) (nbgv) from the `version.json` file at the repository root. That file holds the base version (currently `2.0.0-preview.{height}` while `master` is developing the next release) and requests [SemVer 2.0](https://semver.org/) package versions (`nugetPackageVersion.semVer: 2`); nbgv derives the version height, prerelease tag, and build metadata from the git history, and `version.props` maps the computed values onto the assembly and package version properties.
|
||
|
||
Stable (public-release) versions are produced **only** from a canonical `release/<major>.<minor>` branch (e.g. `release/2.0`, `release/2.1`) — never from `master`, a tag, or any other branch — and only at the exact commit whose `version.json` carries the plain `<major>.<minor>.<patch>` version with no prerelease label (e.g. `2.0.0`). Patch numbers increase by exactly one per release on their line (`2.0.0` → `2.0.1` → `2.0.2`); a new minor line resets the patch to zero (`2.1.0`). See **[Release process](ReleaseProcess.md)** for the full branch/version model and the step-by-step procedure for cutting a release, shipping a patch or minor version, backporting a fix, and promoting a stable candidate.
|
||
|
||
The XRegistry, WoT Connectivity, Vision, Robotics, Redundancy, Positioning,
|
||
OpenUSD, ISA95, AI, and DI package families remain preview packages even when
|
||
the root version is stable. Their numeric version follows the root version:
|
||
for example, a stable `2.0.0` root produces `2.0.0-preview.N` for these
|
||
families (`N` a committed, manually curated number in `preview-version.props`
|
||
that always sorts above every already-published preview — see
|
||
[Release process](ReleaseProcess.md)) and `2.0.0` for the other packages. The
|
||
same policy applies to the Robotics and Vision MCP extensions and the
|
||
OpenUSD connector tools. The package validation manifest
|
||
(`.azurepipelines/validate-nuget-package-set.ps1`) records the root package
|
||
version, a `preview`/`stable` `channel`, and the distinct family versions so
|
||
the signed release workflow can promote an intentional mixed-version set.
|
||
|
||
> The earlier 1.x packages used a different, spec-derived scheme in which the first two digits encoded the embedded NodeSet spec version (for example `1.5.378.x` corresponds to OPC UA spec V1.05, mapped to release branches such as `release/1.4.372`). That scheme no longer applies from 2.0 onward.
|
||
|
||
## Continuous integration
|
||
|
||
**GitHub Actions owns pull-request validation and the weekly full-scope validation.** [`.github/workflows/buildandtest.yml`](../.github/workflows/buildandtest.yml) runs the complete build and test workload on GitHub-hosted runners for every triggering branch, and [`.github/workflows/nightly.yml`](../.github/workflows/nightly.yml) runs the full-scope workload on the weekly schedule and on demand. The other workflows in [`.github/workflows/`](../.github/workflows) cover CodeQL, container images and the opt-in stress and stability suites.
|
||
|
||
Azure Pipelines ([`azure-pipelines.yml`](../azure-pipelines.yml) plus the templates in [`.azurepipelines/`](../.azurepipelines)) no longer runs on pull requests or on the weekly full-scope schedule: the Azure context was dropped from the master ruleset, the pipeline's PR trigger is `pr: none`, and the YAML `schedules:` block has been retired. Azure still runs on pushes to `master` and can be queued manually for recovery, where it **duplicates** coverage rather than supplying any of it; see [Migration status](#migration-status) for what remains to switch off.
|
||
|
||
Pull requests targeting `master378` or a `release/*` line are unaffected — Azure Pipelines evaluates a pull request against the *target* branch's copy of `azure-pipelines.yml`, and those branches keep their own copy and their own required Azure context.
|
||
|
||
### What runs where
|
||
|
||
One table describes the entire migrated workload: `$Profiles` and `$BuildProfiles` in [`.github/scripts/get-ci-matrix.ps1`](../.github/scripts/get-ci-matrix.ps1). Both workflows call that script, so a profile added there appears in both without editing any YAML.
|
||
|
||
| | Pull request (`buildandtest.yml`) | Full scope (`nightly.yml`) |
|
||
| --- | --- | --- |
|
||
| Test profiles | Windows net48, Windows/Linux/macOS net10.0 | the above plus net472, net9.0, net8.0, netstandard2.0, netstandard2.1, the Debug legs, and the tiers that lift the category filter |
|
||
| Solution builds | every `.slnx` on Windows for net48/net10.0 × Debug/Release, `UA.slnx` for net472/netstandard2.0 and the Linux TFMs | every `.slnx` on Windows for all seven TFMs × Debug/Release, plus the Linux legs |
|
||
| Native AoT | linux-x64, osx-x64, osx-arm64 | the above plus win-x64 |
|
||
| Coverage | project floors and the graduated patch gate | project floors only |
|
||
| Trigger | every push and pull request | weekly schedule and `workflow_dispatch` |
|
||
|
||
A profile pins its target framework through `CustomTestTarget`, not `--framework`, because that is the mechanism [`targets.props`](../targets.props) uses. Two profiles are not runnable target frameworks at all: `netstandard2.0` hosts its tests on net48 and `netstandard2.1` hosts them on net8.0, so each profile records both what it builds with and what its tests run on.
|
||
|
||
GitHub refuses to start a run whose matrices expand past 256 jobs, and the limit cannot be raised. The project fan-out is therefore **batched**: each matrix entry carries several projects that [`.github/scripts/run-dotnet-tests.ps1`](../.github/scripts/run-dotnet-tests.ps1) runs in sequence, keeping per-project results and per-project timeouts. The batch size is the smallest one that fits the budget, so it grows by itself as test projects are added instead of silently truncating the matrix. [`CiMatrixScriptTests`](../tests/Opc.Ua.Tools.Tests/CiMatrixScriptTests.cs) asserts that both scopes fit, that every `*.Tests.csproj` on disk is either scheduled or explicitly excluded, and that a narrowed matrix can never expand to nothing.
|
||
|
||
The executor fails a project when its build fails, when the TRX counters report any failure, when it produces **no** TRX at all, when nothing in it actually ran, or when it outlives its per-project ceiling. A "no tests ran" outcome is a failure, not a pass — and that includes a suite whose tests were *all* skipped, which records a non-zero total while verifying nothing.
|
||
|
||
That per-project ceiling is a **single combined budget** covering the project's build *and* its test run, measured by one stopwatch. The matrix derives each job's `timeout-minutes` from it as `20 + projectCount × perProjectTimeout`, so spending it twice per project would let a batch outlive its job: GitHub would cancel the run, and a cancelled job produces neither the executor's per-project annotation nor its results. A batch whose budgets add up past the job ceiling is an error in `get-ci-matrix.ps1` rather than a clamped `timeout-minutes`, for the same reason. `CiMatrixScriptTests` pins both halves of that arithmetic.
|
||
|
||
The verdict comes from the emitted TRX rather than from the `dotnet test` exit code, and lives in [`.github/scripts/get-test-verdict.ps1`](../.github/scripts/get-test-verdict.ps1) so it can be tested on its own — see [`CiTestVerdictTests`](../tests/Opc.Ua.Tools.Tests/CiTestVerdictTests.cs). A non-zero exit is tolerated when, and only when, the results record at least one **passing** test and no failure, error, timeout, abort or `passedButRunAborted`. That combination means the host died during process **exit**, after the last test and every teardown had already run; failing it would report a false red. It is not a macOS quirk — Windows hosts do it too (run 35714133848, `test-windows-net48 (5/30)`: `Opc.Ua.Client.Tests` reported 256 passed, 0 failed, host exit 1). A host that dies mid-run leaves a non-zero counter and is still rejected, and a run in which every test was skipped is rejected whatever the exit code says. Every tolerated run raises a warning annotation and is labelled in the job summary, so a host that keeps dying stays visible. This matches the Azure gate in [`.azurepipelines/test.yml`](../.azurepipelines/test.yml).
|
||
|
||
#### Why a project can be skipped
|
||
|
||
`RestrictForLegacyTfm` in [`targets.props`](../targets.props) turns a project that does not support the requested `CustomTestTarget` into an empty shell with `IsTestProject=false`. Running `dotnet test` against one of those produces no TRX, which the executor treats as a failure — so it first probes `dotnet msbuild -getProperty:IsTestProject` and records the project as **not applicable** instead. Those rows appear in the job summary, so a project that quietly stops being applicable everywhere is visible rather than invisible.
|
||
|
||
### Test tiers
|
||
|
||
The pull-request profiles filter out `TestCategory=LongRunning` and `TestCategory=Stress`. The tiers those filters leave out run elsewhere:
|
||
|
||
| Tier | Where it runs |
|
||
| --- | --- |
|
||
| `LongRunning` categories in mainline projects | `linux-long-running` profile, `nightly.yml` only |
|
||
| `Opc.Ua.Subscriptions.Durable.Tests` | `windows-durable` / `linux-durable` profiles, `nightly.yml` only |
|
||
| `Opc.Ua.Stress.Tests` | [`.github/workflows/stress-test.yml`](../.github/workflows/stress-test.yml), opt-in |
|
||
| `Opc.Ua.Aot.Tests` and the `.Historian` / `.Mcp` companions | `aot-test` job (both workflows) — published and run as native executables, not through `dotnet test` |
|
||
| `Opc.Ua.OneFuzz.Validator.Tests` | `fuzz-drop` job — it pins net10.0 to match the drop it validates |
|
||
|
||
### Running the full scope
|
||
|
||
`nightly.yml` runs weekly at Sunday 02:00 UTC. You can also start it from the Actions tab (or `gh workflow run nightly.yml`) with:
|
||
|
||
| Input | Effect |
|
||
| --- | --- |
|
||
| `include_macos` | Include the macOS profiles (default `true`) |
|
||
|
||
It uploads a `workload-manifest` artifact recording every (project × profile) tuple the run intended to cover, so a run's scope can be compared against another inventory instead of inferred from job names.
|
||
|
||
#### The fuzz crash corpus
|
||
|
||
The checked-in corpus under [`fuzzing/`](../fuzzing) runs on every pull request. The full **crash corpus** — about 22k inputs from earlier fuzzing campaigns, formerly the Azure secure file `FuzzingArtifacts.zip` — lives on the orphan branch `fuzz-corpus` and takes too long to replay per pull request. The `crash-corpus` job in `nightly.yml` checks out a commit of that branch pinned in the workflow (`FUZZ_CORPUS_COMMIT`), overlays it onto the matching `fuzzing/` projects and runs `Opc.Ua.Encoders.Fuzz.Tests` on every non-macOS profile. It needs no secrets, and the nightly summary fails if it was skipped. See [`fuzzing/CrashCorpus.md`](../fuzzing/CrashCorpus.md) for how to add inputs and update the pin.
|
||
|
||
### Required checks and coverage
|
||
|
||
Three concerns are deliberately kept apart:
|
||
|
||
| Concern | Check | In the branch ruleset? |
|
||
| --- | --- | --- |
|
||
| Every build and test passed | **`build-and-test summary`** | **Yes — required** |
|
||
| Every sample container image built | **`images summary`** | **Eligible — require it** |
|
||
| Coverage meets the thresholds | **`code coverage`** | **No — advisory** |
|
||
|
||
`build-and-test summary` is a single rollup job on purpose. The jobs underneath it are matrix-generated, so their names change whenever a test project or a profile is added; requiring a generated name would break as soon as the matrix changed. The job runs on `always()` and inspects `needs.*.result` itself, calling `exit 1` on anything that is neither `success` nor `skipped` — a failing dependency therefore shows as a red X.
|
||
|
||
`always()` is not optional here: a job *skipped* because a dependency failed surfaces to GitHub as `skipped`, and a required check reporting `skipped` is treated as **satisfied**. Without `always()` the rollup would wave a red build straight through.
|
||
|
||
For the same reason the workflow carries no `paths:` filter. A workflow filtered out by `paths` never reports its checks at all, and a required check that never reports leaves a pull request permanently "Expected — waiting for status to be reported". The decision is applied inside the `discover` job instead: a docs-only pull request skips the expensive jobs and still gets a legitimate green summary.
|
||
|
||
That decision is a **deny-list**, not an allow-list of build inputs, and it lives in [`.github/scripts/get-path-relevance.ps1`](../.github/scripts/get-path-relevance.ps1) so it can be tested — see [`CiPathRelevanceTests`](../tests/Opc.Ua.Tools.Tests/CiPathRelevanceTests.cs). It fails dangerously in one direction only: calling a real change irrelevant skips the build, test and AoT jobs while the required summary still reports **success**, so a broken change merges behind a green check. An allow-list of extensions cannot be kept complete — the source generators consume `.xml` and `.csv` design files (the `AdditionalFiles` items in [`src/Opc.Ua.WotCon/Opc.Ua.WotCon.csproj`](../src/Opc.Ua.WotCon/Opc.Ua.WotCon.csproj)), test projects carry XML and JSON fixtures, and `.editorconfig` is enforced at build time. Only Markdown and the `docs/` tree, which holds nothing but Markdown and images, are skippable; everything else builds.
|
||
|
||
[`.github/workflows/docker-image.yml`](../.github/workflows/docker-image.yml) (`Images CI`) follows the identical pattern for the sample container images. Its build legs are named `build-and-push-image (refserver)`, `(boilerserver)` and so on, so they cannot be pinned in a ruleset either; **`images summary`** is the fixed name that rolls all of them up. One broken image fails it, because a matrix job aggregates to `success` only when every leg succeeded. Its `pull_request` trigger carries the same branch list as the `CI` workflow and no `paths-ignore`, so both gates report on exactly the same set of pull requests; the docs/tests exclusion moved into its own `discover` job. Add `images summary` to the ruleset alongside `build-and-test summary`.
|
||
|
||
The coverage check reports a clean failure when the thresholds are missed, so a miss is visible on the pull request, but it never blocks the merge. Do not add it to the ruleset — that would make a coverage dip unmergeable, which is not the intent.
|
||
|
||
> Azure Pipelines no longer posts any check on a pull request targeting `master`; `build-and-test summary` is the only required check. On the branches where Azure still validates pull requests (`master378`, `release/*`), note that its `Tests passed` context is fail-*closed* rather than fail-red: the verdict lives in the stage `condition`, so when a test stage fails the stage is skipped and Azure posts **no check at all** — the required check stays unfulfilled and the merge stays blocked. You will see the failing test job in red and `Tests passed` still waiting, rather than two red checks. Verified on build 16613.
|
||
|
||
#### How coverage is measured
|
||
|
||
Every test matrix entry collects coverage while it runs and publishes its raw Cobertura fragment as an artifact. The coverage job then downloads every fragment the run produced, merges them **once** with ReportGenerator, and evaluates the merged report. It never re-runs the tests — doing so serialises a suite that was deliberately fanned out across matrix jobs and blows the job timeout.
|
||
|
||
Coverage is collected only on .NET 8.0 and newer hosts. `coverlet.collector` 10.x ships build assets for net8.0+ only, so a .NET Framework test host cannot load the `XPlat Code Coverage` collector at all — VSTest merely warns and writes nothing. The expander therefore never requests coverage on a `net4*` profile, and `CiMatrixScriptTests` asserts that. The long-running and durable tiers also opt out: they re-run projects the filtered legs already covered, so folding their numbers in would double-count them.
|
||
|
||
The evaluation is [`.azurepipelines/check-coverage.ps1`](../.azurepipelines/check-coverage.ps1), driven by [`coverage-thresholds.json`](../coverage-thresholds.json):
|
||
|
||
| Check | Behaviour |
|
||
| --- | --- |
|
||
| **Project floor** | Total line and branch rates must meet the absolute floors in `coverage-thresholds.json`. The `ignore` globs are applied here too, so samples, tests and generated code do not count. |
|
||
| **Patch coverage** | On pull requests, lines you added or modified must reach a floor that is **graduated by how much changed** — see below. The uncovered changed lines are listed by file. |
|
||
| **Baseline delta** | Reports how total coverage compares with the recorded `baselineLineRate`. Warning only, even within this advisory check. |
|
||
|
||
Ratchet `minimumLineRate`, `minimumBranchRate` and `baselineLineRate` **upward** as coverage improves; never lower them to turn a red check green.
|
||
|
||
> The script still lives under `.azurepipelines/` because it is shared, not because it is Azure-specific. The directory keeps its name so the reusable PowerShell helpers there do not all have to move at once.
|
||
|
||
Two things about the `ignore` globs regularly catch people out. `samples/**` is ignored, so a sample can carry
|
||
tests for its own sake — a wrong kinematics solver would make a sample lie — without those lines counting
|
||
toward the patch gate. `tools/**` is **not** ignored, so anything you change under `tools/` is measured like
|
||
product code, and an assembly no test project references contributes changed lines that are counted as
|
||
**uncovered** because no report mentions them. If you add code there, make sure a test project loads the
|
||
assembly, or the patch gate will read far lower than the per-file numbers suggest.
|
||
|
||
##### Patch coverage is graduated by patch size
|
||
|
||
A coverage percentage over a handful of lines carries almost no information. One uncovered line in a two-line fix reads as 50%, and a flat floor would fail it — which teaches authors to ignore the check rather than act on it. So the requirement scales with how much actually changed:
|
||
|
||
| Coverable changed lines | Floor | Below the floor |
|
||
| --- | --- | --- |
|
||
| 1 – 10 | 50 % | :warning: **warning**, check still passes |
|
||
| 11 – 100 | 60 % | :warning: **warning**, check still passes |
|
||
| more than 100 | `patch.target` − `patch.threshold` (75 %) | :x: **failure** |
|
||
|
||
Only changes larger than the last band can fail the patch check. At that size the percentage is meaningful, and a large untested change is exactly what the check exists to catch. Below it you still get a warning naming the uncovered lines, so the signal is never silent — it just does not block.
|
||
|
||
The bands live in `patch.bands` in [`coverage-thresholds.json`](../coverage-thresholds.json). They are consulted in order and the first band whose `maxChangedLines` covers the patch wins; set `enforced: true` on a band to make it blocking. Anything larger than the last band falls through to `patch.target` − `patch.threshold` and is always enforced.
|
||
|
||
Remember that the coverage check as a whole is advisory and stays out of the branch ruleset — an enforced band produces a red `Code coverage` check, not a blocked merge.
|
||
|
||
##### Codecov
|
||
|
||
The merged report is also uploaded to [codecov.io](https://codecov.io), which is where the pull-request comment, the file-by-file diff view and the coverage trend live. **Codecov does not gate.** Both of its status checks are `informational: true` in [`codecov.yml`](../codecov.yml), because two gates with two sets of thresholds would eventually disagree about the same pull request and the easier one to silence would win. The rules that actually decide are the ones above.
|
||
|
||
Each CI system uploads the report it merged, under its own flag (`azure`, `actions`), since the two matrices deliberately cover different legs.
|
||
|
||
The upload is optional on both systems and never fails a build:
|
||
|
||
| | Turn it off with | Also skipped when |
|
||
| --- | --- | --- |
|
||
| GitHub Actions | the `ENABLE_CODECOV` workflow `env` (default `'true'`) | the `CODECOV_TOKEN` secret is unavailable, as on fork pull requests |
|
||
| Azure Pipelines | the `enableCodecov` pipeline parameter (default `true`) | the `CODECOV_TOKEN` secret variable is unset |
|
||
|
||
Keep the `ignore` list in `codecov.yml` in step with the one in `coverage-thresholds.json`, or the two will report on different code.
|
||
|
||
#### Where the numbers appear
|
||
|
||
The script renders a markdown summary that both systems surface, so you never have to open a raw log to see why coverage moved:
|
||
|
||
- **GitHub Actions** — appended to the run's job summary, and posted as a single sticky pull-request comment that is updated in place on each run. Threshold misses additionally appear as run annotations. On a pull request **from a fork** the token is read-only, so the comment is skipped and only the job summary is written.
|
||
- **Azure Pipelines** — attached to the build summary via `##vso[task.uploadsummary]`, alongside the usual Code Coverage tab and the Codacy upload.
|
||
|
||
Both also publish the merged HTML report as a `coverage-report` artifact.
|
||
|
||
> The two systems report **different numbers**, and that is expected. GitHub Actions merges every profile that collects coverage, whereas Azure Pipelines merges only its own fast legs. The GitHub figure is the representative one — and now the only one a pull request sees, since Azure no longer runs on pull requests — and a full-scope run reads differently again because it covers different profiles.
|
||
|
||
To reproduce a coverage failure locally, generate the same report with [`tests/codecoverage.cmd`](../tests/codecoverage.cmd) (or [`tests/codecoverage.sh`](../tests/codecoverage.sh)) and run the script against it:
|
||
|
||
```powershell
|
||
./.azurepipelines/check-coverage.ps1 -CoberturaPath ./CodeCoverage/Cobertura.xml -BaseRef master -SummaryPath ./coverage-summary.md
|
||
```
|
||
|
||
Omit `-BaseRef` to check only the project floor, and `-SummaryPath` to skip the markdown summary.
|
||
|
||
### Reproducing a CI leg locally
|
||
|
||
Both workflows delegate to the same two scripts, so any leg can be reproduced without a runner. Expand the matrix to see what a scope covers:
|
||
|
||
```powershell
|
||
./.github/scripts/get-ci-matrix.ps1 -Scope pr -ManifestPath ./pr-manifest.json
|
||
```
|
||
|
||
Then run one entry's projects exactly as CI would:
|
||
|
||
```powershell
|
||
./.github/scripts/run-dotnet-tests.ps1 `
|
||
-Projects 'tests/Opc.Ua.Core.Tests/Opc.Ua.Core.Tests.csproj' `
|
||
-CustomTestTarget net10.0 -Framework net10.0 -Configuration Release `
|
||
-Filter 'TestCategory!=LongRunning&TestCategory!=Stress' `
|
||
-ResultsDirectory ./TestResults
|
||
```
|
||
|
||
Add `-Coverage` to collect Cobertura fragments, and `-QuietOutput` to redirect child output to a log file instead of the console.
|
||
|
||
### Triggering a pipeline run on a pull request
|
||
|
||
Azure Pipelines no longer builds pull requests targeting `master` (`pr: none`). If you need an Azure run for comparison while the migration finishes, a repository owner or a collaborator with `Write` permission can still start one by commenting:
|
||
|
||
```text
|
||
/azp run
|
||
```
|
||
|
||
`/azp run <pipeline-name>` targets a single pipeline. If a comment appears to do nothing, check that your GitHub organization membership is **public** — Azure Pipelines cannot see private organization members unless they are direct repository collaborators, and it silently ignores their commands.
|
||
|
||
On the branches where Azure still validates pull requests (`master378` and the `release/*` lines, each governed by its own copy of `azure-pipelines.yml`), the definition is configured with **Require a team member's comment before building a pull request**, scoped to *pull requests from non-team members*. That setting lives in the Azure DevOps portal (pipeline → **More actions** → **Triggers** → **Pull request validation**), not in YAML. GitHub Actions applies its own equivalent "Approve and run workflows" gate to outside contributors and to the **GitHub Copilot coding agent**.
|
||
|
||
### Migration status
|
||
|
||
The Actions workflows were added while Azure Pipelines kept every trigger and required context it had, so a gap in the new system could not leave a change untested. Since then the master ruleset has dropped the Azure context, leaving `build-and-test summary` as the only required check, and this repository's PR trigger has been set to `pr: none`.
|
||
|
||
What is still outstanding — each needing repository- or organization-administrator access, and therefore **not** part of the source change:
|
||
|
||
1. Run `nightly.yml` on a trusted SHA and compare its manifest against a full-scope Azure run.
|
||
2. Confirm definition 14's service-side **Pull request validation** setting is off. `pr: none` covers the YAML trigger, but an enabled "Override the YAML PR trigger from here" would still queue builds.
|
||
3. Confirm definition 14's service-side schedule is off and retire the push trigger once the GitHub replacement has proven stable. The YAML `cron` has already moved to `nightly.yml`.
|
||
4. Retire `azure-pipelines-preview.yml` and definition 16's build-completion trigger. Development packages already publish to GitHub Packages from [`.github/workflows/nuget-publish.yml`](../.github/workflows/nuget-publish.yml), so that publisher is a duplicate. Ensure the stale definition 13 cannot restart it.
|
||
|
||
> **Before cutting a canonical `release/2.<minor>` branch:** the `Release` ruleset requires `OPCFoundation.UA-.NETStandard` for *every* `refs/heads/release/*`. A new 2.x branch inherits `pr: none` from `master`, so that Azure context would never report and would block every pull request into the new line. Exclude the new ref from that ruleset — or add a 2.x ruleset requiring `build-and-test summary` — as part of creating the branch. The existing 1.x lines and the frozen `release/2.0.0` keep their own copy of `azure-pipelines.yml` and must keep the Azure requirement.
|
||
|
||
Preserve the Azure definitions, their artifacts, feeds, secure files, pools and service connections — retiring a trigger is not the same as deleting history. The `master378` and 1.x pipelines are out of scope entirely.
|
||
|
||
The weekly full-scope schedule now lives in `nightly.yml`; keep Azure's service-side schedule disabled so both systems do not run the same nightly workload.
|
||
|
||
|
||
## Contributing and pull requests
|
||
|
||
- Fork the repository (or, if you have write access, push a branch prefixed with your username) and open a pull request. You must agree to the [Contributor License Agreement](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf); the "I AGREE" prompt appears automatically on your first PR. See [CONTRIBUTING.md](../CONTRIBUTING.md).
|
||
- Before submitting: all tests pass, code analysis is clean (no new warnings), the change keeps backward compatibility, and security implications are reviewed.
|
||
- The pull-request template asks you to confirm the CLA, added tests/coverage, documentation, a warning-free build, that the `UA.slnx` suite passed on **.NET Framework 4.8** and **.NET 10.0**, and that CI and CodeQL are green.
|
||
- You can run the `opc-ua-codestyle-enforcer` agent to drive analyzer warnings to zero before opening the PR.
|
||
|
||
## Related documentation
|
||
|
||
- [Documentation index](README.md) — all topic guides.
|
||
- [Diagnostics](Diagnostics.md) — telemetry context, logging runtime, metrics, audit events, server diagnostics nodes, and packet capture.
|
||
- [Dependency Injection](DependencyInjection.md), [Certificates](Certificates.md) / [Certificate Manager](CertificateManager.md), [NativeAOT](NativeAoT.md), [Migration Guide](MigrationGuide.md), [What's New in 2.0](WhatsNewIn2.0.md).
|
||
- [Fuzz testing](../fuzzing/Fuzzing.md).
|
||
|
||
Fuzz replay has a dedicated GitHub Actions matrix and a local
|
||
[`fuzzing/Scripts/test-fuzzing.ps1`](../fuzzing/Scripts/test-fuzzing.ps1) entry point.
|
||
It covers the modern framework matrix and applicable .NET Framework 4.8 projects, including
|
||
seed-, dictionary- and script-only changes. Target inventory and fork behavior mappings belong
|
||
in `fuzzing/fuzz-targets.json`, not hardcoded test-count floors. The dedicated
|
||
`fuzz-parity.runsettings` includes generated protocol methods in fuzz-only coverage; it does not
|
||
alter the general coverage policy. Fuzz scripts also set `FuzzCoverage=true` to omit generated
|
||
data-type coverage-exclusion attributes only in those builds; an include filter alone cannot
|
||
override a compiled exclusion attribute. Internal OneFuzz drops must remain uninstrumented locally
|
||
and require owner-supplied configuration and actual worker execution evidence.
|