mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-23 18:39:26 +08:00
fa8d45f2cf
# Description **Server/core audit fixes with regression tests. Latest review-fix validation, merge validation and earlier acceptance results are distinguished below.** This addresses a 95-finding server/core report. Every finding was reviewed with the OPC UA specification knowledge base or, where the standard does not prescribe a C# implementation, the corresponding ownership/configuration contract. Incorrect or withdrawn subclaims remain qualified rather than being presented as additional fixes. The original checkpoint covers session identity/certificate validation and revocation; certificate/key ownership and transactional updates; TCP reconnect, receive/admission and buffer lifetime; HTTP quotas; subscription admission, durability and pooled payloads; file-operation lifecycle; historian aggregates; fluent/state-machine behavior; and related utilities. It includes targeted regressions and directly related documentation. The approved acceptance cleanup is separate in the commit history: | Cleanup | Verified result | | --- | --- | | Invalid identity-policy and KeyCredential fixture inputs | Fixtures explicitly advertise Username and use its generated policy ID. Credential persistence uses valid clear mode inside the existing encrypted admin context. Original permission, lockout and negative encryption assertions remain. | | Native GDS lifetime and concurrent connection failures | Retain ApplicationInstance until client teardown. A deterministic native reproducer proved the test-only channel cap of 10 was exhausted; removing that override restores the existing bounded default of 1000. No global serialization or production quota change. | | Stress certificate ownership and close accounting | Balance two helper references and the fake transport's server-certificate ownership. Replace the invalid event-count bound with exactly-once physical disposal, opens = closes, zero active entries and one logical entry lifetime. | | Participant deadlines | Use the injected TimeProvider with the existing cross-framework WaitAsync overload. Tests pin one tick before, exact expiry, terminal notification, recovery and timely completion. | | Reproducibility and platform assertions | Preserve canonical WoT bytes/hashes, explicit display-culture contracts and exact numeric-vector assertions. Accept Apple's CryptographicException subtype without weakening disposal balance. | | Missing user-signature proof | Reject null/empty proofs before OS crypto. Return BadUserSignatureInvalid per Part 4, 5.7.3.3. None-policy and valid-signature controls remain. A CI-only test nullable-context error was corrected without changing production behavior. | | Durable sample restore and shared test storage | Dequeue waits for actual batch residency instead of treating failed restore completion as success, and restored files close before deletion. Five deterministic regressions failed before the fix. The two fixtures that clear the shared batch directory are isolated; all original 3,000-item assertions remain unchanged. | ## Latest review follow-up Commit `828856850219518970e8e2f580b8f94b3c1fc748` fixes the deferred robotics disposal reported in the latest review. `RobotIntentNodeManager.DisposeAsync` now joins a shared controller-host drain and then explicitly awaits base node-manager disposal. A controller's bounded shutdown wait may defer its cleanup, but no longer lets the node-manager override finish early. Concurrent callers, including an asynchronous call after synchronous `Dispose`, cannot bypass the drain after the host list has been detached. Synchronous disposal remains nonblocking for deferred work. Deferred initialization failures are logged and retained, and base cleanup failures propagate to asynchronous callers. The existing controller-level bounded disposal contract is unchanged; the node-manager contract is documented in `docs/Robotics.md`. | Regression requirement | Evidence | | --- | --- | | Wait for deferred hosts; preserve synchronous compatibility and concurrent joins | `NodeManagerAsyncDisposalWaitsForDeferredHostsAsync`: four cases covering both entry-point orders and zero/positive host shutdown waits. All four failed before the fix. | | Wait through base cleanup and observe failures | `NodeManagerDisposalWaitsForBaseCleanupAndReportsItsFailureAsync`: four cases hold the actual base-resource semaphore, verify pending callers after the host finishes, then check success or the same propagated exception and exactly-once cleanup. | The focused `IntentHostingCoverageTests` and `IntentControllerHostTests.Dispose` selection passed **30/30 on net10.0 and 30/30 on net48**, with no failures or skips. The final net10 solution build passed with zero errors and only the four previously documented CA2249 warnings in unchanged fuzz-test code. No new warnings, analyzer suppressions, dependencies, or weakened assertions were added. The review thread received a commit-specific reply and was explicitly resolved. This verification is scoped to the robotics feedback; the earlier full Subscriptions-run limitation remains recorded in the merge section below. ## Documentation review follow-up (2026-09-14) Commit `f9a9686fcc5d3cfd19cbbc8c2d826e20f5c7e72f` addresses the six follow-up threads on documentation and implementation rationale. Each has an in-thread reply citing the commit and an explicit resolution. Multiline XML documentation now covers the declarations introduced or modified throughout the entire PR, including internal contracts, samples, tests, and public members of related private helpers. A syntax-aware inventory checked **2,031 declarations across all 256 PR C# paths with zero documentation gaps**. A supplementary check also covers **75 previously unchanged public members** of private helpers involved in the changed code. Token comparison across the conditional-compilation branches proves this follow-up changes **no executable C# or test assertions**. Project-file changes are rationale comments only. The implementation questions are documented at their source: | Question | Resolution | | --- | --- | | Certificate assembly friendship | Server Push configuration reuses the internal explicit-subject CSR helper with the existing key; no public crypto API was added. | | Core.Types assembly friendship | Server builders use internal transition callbacks/revision guards; Server.Tests checks the revision deterministically without creating a public compatibility contract. | | Always-single-reader notifications | One dequeuer preserves event order; optional concurrency fans out within one event and joins before the next event. | | Asynchronous disposal completion | `DisposeAsync` already joins the completion published after admitted operations and all cleanup attempts. The compatibility `Dispose` remains nonblocking to avoid deadlocking an admitted callback against its own drain. | The requested detailed review disposition follows. It distinguishes actual fixes from confirmed policies and incorrect subclaims rather than counting every comment as a new runtime change. ### Detailed disposition of review 5191795284 The [SDK review](https://github.com/OPCFoundation/UA-.NETStandard/pull/4477#pullrequestreview-5191795284) was submitted against `1c4a9df6c`, before the subsequent merge and feedback fixes. Its **15 inline threads** have each received an action-specific reply and an explicit resolution. A fresh GraphQL query verified those replies and resolution states; the fixes in `b8a59990f` remain ancestors of the current branch. | Review concern | Action and disposition | Concrete regression evidence | | --- | --- | --- | | Injected certificate-store providers bypassed | **Fixed.** Registration, configuration snapshots, and validation use the injected providers. Explicit custom types remain authoritative; the built-in Directory catch-all is deferred so native X509 paths and later custom providers are not captured. Failed store opens dispose the created store. | `InjectedProviderResolvesRegisteredAndConfiguredTrustStores` covers default-provider ordering and both registration paths; provider validation/open-failure cases and the unchanged `TrustedCertificateInjectedViaIndependentX509StoreIsObservedAsync` cover real use and independent-store visibility. | | Unguarded timer-thread leadership callbacks | **Fixed.** Each subscriber's exception is logged independently and cannot suppress subsequent subscribers or fail lease operations. `IsLeader` reads the deadline without invoking application callbacks. | `ThrowingLeadershipSubscriberDoesNotFailAcquireOrDisposeAsync`, `ExpiryNotifiesOtherSubscribersWhenOneThrowsAsync`, and `IsLeaderDoesNotDispatchExpiryNotificationsAsync` were all demonstrated failing before the fix. | | Legacy pending-key provider accepts an unfinishable regenerated CSR | **Fixed by explicit capability validation.** A provider implementing only `IPendingCertificateKeyStore` receives `BadNotSupported` before key generation or persistence when regeneration is requested. Existing-key CSR/update remains supported. Regeneration requires matching-only consumption; no unsafe destructive probing was introduced. | `LegacyPendingProviderRejectsRegenerationBeforeCreatingOrSavingAKeyAsync` checks no key or store mutation; `LegacyPendingProviderCompletesExistingKeySigningAndUpdateAsync` proves the supported flow completes. | | Shutdown waits behind user deactivation | **Fixed.** Accepted deactivations drain before the master's lifecycle gate is taken. Configuration retains its binding until the shared drain completes, including concurrent shutdown callers and direct master disposal. | Four cases of `ShutdownDrainsDeactivationOutsideTheSessionClosingGateAsync` exercise real configuration/binding/ServerInternalData/master reentry, retained address space, exact session/subscription closure, and no production cancellation shortcut. | | Subclass cleanup runs under the operation-lifetime lock | **Fixed.** Cleanup starts outside that lock. A pre-published completion joins all disposers, and only the active asynchronous teardown context may use guarded helpers. Captured contexts lose that privilege when the callback exits. | `SynchronousOwnedCleanupLetsSamplingWorkerReachClosedAdmissionAsync`, `DisposeCoreCanUseGuardedMembersWithoutReopeningCallerAdmissionAsync`, captured-context rejection, and exact cleanup-failure/ownership assertions cover both synchronous and suspended cleanup. | | Discovery requestId-zero chunk read after pool return | **Fixed by the earlier merge.** Discovery validation precedes `SaveIntermediateChunk`; no read occurs after that path can return the buffer. The ordering is retained. | `DiscoveryChannelReadsTheFirstChunkBeforeItIsReturnedAsync` uses a poisoning pool and the requestId-zero path. | | T1.8 OPN cleanup misses post-decryption throws | **Fixed by the earlier merge and pinned with new regressions.** Both sequence rejection and certificate mismatch release the decrypted body and input ownership. | `OpenSecureChannelRejectionAfterDecryptionReturnsEveryRentalAsync` checks the exact audit status and pool conservation. Omitting either corresponding release in an isolated snapshot made its case fail; no mutation remains. | | T1.2 mount-root protection depends on the provider | **Fixed at the host boundary.** Separator-only aliases resolve to the protected root. Traversal, empty/whitespace segments, backslashes, and drive/stream qualifiers are rejected before arbitrary providers are invoked. Valid nested paths and root destinations remain supported. | A deliberately permissive provider is used by `NonCanonicalObjectPathsCannotDeleteMoveOrCopyAsync` and invalid-target cases; exact zero-call checks are paired with legitimate nested-path/root-destination controls. | | Inline trusted/issuer entries now face CRL checks | **Intentional strict behavior, documented and tested.** A configured backing store with strict unknown-status rejection requires current revocation information. Explicit-only lists without a store retain their no-store behavior. Actual revocation cannot be suppressed; a matching store entry's policy takes precedence. | `InlineIssuerWithStoreHonorsStrictUnknownRevocationPolicyAsync`, `InlineIssuerRevocationIsNeverSuppressedAsync`, store-precedence cases, and explicit-only controls preserve the missing-information versus actual-revocation distinction in Part 4 6.1.3. | | RSA minimum applied to unspecified ECC identifiers | **Fixed at the application-certificate check.** The RSA minimum is compared only with an actual RSA public key. An unspecified ECC identity is not replaced simply because its key size is below an RSA threshold. | `SilentCertificateCheckPreservesUnspecifiedEccIdentityAsync` checks DER, thumbprint, store contents, and private-key retention; explicit RSA and HTTPS minimum controls remain. | | Failed directory refresh wedges corrective mutations | **Fixed.** Recoverable automatic pre-refresh failures are logged and leave reconciliation pending, but do not block corrective provider mutations. Explicit refresh errors, capacity admission, caller cancellation, and actual provider denials remain enforced. | `CorrectiveDeleteRecoversAfterPersistentOverLimitRefreshFailureAsync` converges an externally over-limit directory back to its exact allowed contents. Capacity, cancellation/token, and six refresh-error controls check negative paths. | | Timed-transition rejection logs at 10 Hz | **Fixed.** Repeated `(state, revision, status)` warnings are deduplicated without changing the retry cadence. A different status or state entry permits another warning. | `RepeatedTimedRejectionLogsOnceAndRecoversAsync` checks one warning over eleven rejections and success on the next attempt. Status-change and same/different-state reentry controls pin reset behavior. | | Bad unsubscribe strands retired-generation bookkeeping | **Fixed.** Retired all-event bookkeeping completes even when unsubscribe reports Bad; the first Bad is still returned to the caller. | Good and BadCommunicationError cases of `RetiredAllEventsDeletionCompletesBookkeepingAndReleasesGenerationAsync` check event/snapshot removal, generation release, and exactly one unsubscribe without finalization replay. | | CSR subject parsing and fallback | **Fixed for existing and regenerated keys.** Omitted subjects fall back to the actual stored certificate. Regeneration resolves the public certificate's subject before creating a key. Malformed or unresolvable subjects return `BadInvalidArgument` before pending-key mutation. | Store-only fallback, real-generator null/empty cases, and missing-subject/malformed-DN controls verify the signed CSR, matching fresh pending key, unchanged active identity, and no destructive side effects. | | Committed RemoveUser change reported as failed | **Fixed for RemoveUser and ModifyUser.** Accepted cleanup is still awaited and close failures remain logged, but cannot replace the committed mutation result. Precommit failures retain their original result and side effects. | `CommittedUserChangeKeepsResultAndPublishedUsersWhenCloseFailsAsync` checks published Users, Good result, logging, and continued closes. `FailedUserChangeKeepsPublishedUsersAndDoesNotCloseSessionsAsync` checks BadNotFound with no publication or close. | | Alleged pooled decoded-request aliasing | **No runtime redesign needed; reviewer rebuttal confirmed.** The decoder copies the relevant input data. Misleading ownership comments were corrected rather than introducing a new dispatch model. | `DecodedRequestRetainsValuesAfterInputBuffersAreReturnedAsync` checks the exact decoded NodeId and ByteString after returned arrays are poisoned. | The current documentation-only follow-up rechecked **104 Core, 473 Server, and 4 Configuration cases** covering these concerns: **581 passed, zero failed or skipped**. The final net10 solution build also passed with **zero warnings and errors**. The complete earlier Core/Server/Configuration and net48 results remain listed separately rather than being presented as rerun current-head acceptance. ## Earlier review follow-up Commit `b8a59990fe2e8e967a12ffd5d9158beed7831143` addresses all 19 review threads. Each thread has an in-thread reply naming this commit and was explicitly resolved after verification; the final paginated query found 19 resolved and zero unresolved threads. The follow-up fixes injected certificate-store selection (including default provider ordering and native X509 paths), actual-RSA minimum enforcement, and CSR capability/subject validation before key mutation. Node-manager teardown uses simpler scoped ownership and drains accepted user revocation outside the master gate; committed user results and retired-event cleanup remain correct when cleanup reports failure. File mutations can recover after failed refresh without bypassing capacity or cancellation, provider paths protect mount roots, and repeated timed rejections no longer flood logs. Leadership subscriber failures are isolated without callbacks from `IsLeader`. Regressions also pin the already-merged OPN/discovery buffer fixes and prove decoded request values do not borrow returned input buffers. The strict inline issuer revocation policy is documented and tested, not weakened. Existing tests were changed only for the reviewed behavior: the incorrect committed-user result was corrected, and inline-issuer/root-alias controls were expanded. ### Review-fix validation | Suite | Scope | Framework | Passed | Existing TRX NotExecuted | | --- | --- | --- | ---: | ---: | | Core | Complete project | net10.0 | 4,970 | 88 | | Server | Complete project | net10.0 | 6,414 | 69 | | Configuration | Complete project | net10.0 | 231 | 0 | | Core | Certificate, lease and buffer compatibility slice | net48 | 153 | 0 | | Server | Reviewed lifecycle, user, file, timer and CSR compatibility slice | net48 | 692 | 0 | | Configuration | Complete project | net48 | 231 | 0 | | CSR provider regressions | Final portable-mock follow-up | net10.0 | 11 | 0 | All runs exited successfully with normal concurrency and leak detection. The Server console reports 5 skips; its TRX additionally records existing assumption/explicit cases, hence the 69-record count above. The Framework build caught two modern-only `ValueTask.CompletedTask` usages in new mock setup; only those expressions changed to the equivalent portable `default(ValueTask)`. The complete modern suites precede that test-only correction; all production files remained byte-identical, and the corrected fixture passed on both targets. The final net10 solution build succeeded with 0 errors and the same 4 CA2249 warnings in unchanged fuzz-test code. Diff checks and changed-line whitespace checks are clean; whole-file formatting still identifies 10 unchanged baseline lines, which were not reformatted outside the feedback scope. The committed 35-file tree was hash-verified against the validation inputs. This is the review-feedback verification scope, not a rerun or sign-off of the historical complete platform/native/soak acceptance campaign below. ## Latest upstream merge Merge `9d9bf679f317164c5b2b90924cd3cdc27a86d451` integrates `master` through `162fc5ebb` (#4486), including the AddNodes child-name index, shared certificate-error reporting, CTT sample-data corrections, and the accompanying regressions and documentation. The CreateSession conflict preserves this branch's certificate ownership, required certificate, application-URI, and channel-certificate checks. The validation result now retains nested errors through `ThrowIfInvalid`, allowing the upstream shared reporting helper to mask trust, policy, and revocation failures consistently without losing audit detail. The new indexed AddNodes regression initially attempted to reuse a deterministic NodeId after renaming its existing node. Renaming frees the BrowseName, not that NodeId. `IndexedBrowseNameReusePreservesExistingNodeIdentityAsync` now proves both contracts: the derived-ID collision leaves the original node intact, then a fresh explicitly requested ID can reuse the old name while the new name remains protected against duplicates. No production identity rule was relaxed. The incoming seeded random-index test uses the existing `UnsecureRandom` wrapper with the same seed and sequence; no new analyzer suppression was added. | Local merge validation | Framework | Passed | Existing exclusions | | --- | --- | ---: | ---: | | Core, complete project | net10.0 | 5,081 | 88 | | NodeState / BaseInstanceState | net10.0 | 579 | 0 | | Server node management, certificate and session-security selection | net10.0 | 112 | 0 | | InformationModel node-management services | net10.0 | 29 | 1 | | NodeState / BaseInstanceState | net48 | 578 | 0 | | Core certificate validation, error reporting and TCP buffers | net48 | 111 | 0 | | Server node management, certificate and session-security selection | net48 | 112 | 0 | All selected runs exited successfully at normal concurrency: **6,602 passing test executions**. Modified test assemblies were explicitly rebuilt, and the combined name/identity regression was verified by name in both net10 and net48 results. No skips or weakened assertions were introduced. The final `dotnet build UA.slnx -c Release --no-restore -m:1` with `CustomTestTarget=net10.0` succeeded with **zero warnings and zero errors**. The resolved source and adjusted tests pass whitespace verification, and their source hashes match the validation inputs. This is merge-specific verification. Earlier review results and the previously reported incomplete full Subscriptions runs below remain scoped to their own commits; they were not reopened or relabeled as passing by this operation. ## Previous upstream merge (2026-09-15 morning) Merge `c4694e20ab089c555af233642042133ff3a03ac8` integrates `master` through `484d6c887`, retaining #4481's shared Like parser/GDS query fixes, #4488's RequestHandle recovery in ServiceFaults, and #4487's expired-session and durable-transfer corrections. Three conflict resolutions preserve both sides: - Alias searches now use the shared `LikePattern` implementation, including upstream malformed-list validation. A timeout overload retains this branch's 100 ms per-alias deadline and `BadTimeout` reporting without keeping a second wildcard translator. The shared matcher's original unlimited overload remains unchanged for existing GDS/ContentFilter callers. New regressions cover bounded prefix/list scans, reuse after timeout, and the alias service result. - Expired activation closes outside the session-manager gate using upstream's once-only timeout claim, audit, and diagnostic accounting. This branch's original-channel and security-policy checks remain in place. - TCP buffer tests retain this branch's ownership, poisoning-pool and post-decryption checks alongside the upstream RequestHandle/fault overload tests; neither set was discarded. The upstream HTTPS binary behavior is intentionally retained: a decoder `ServiceResultException` produces HTTP 200 with an OPC UA ServiceFault carrying the decoder status, response timestamp, and recoverable RequestHandle, rather than the previous HTTP 500 text response. Other exceptions retain their existing HTTP error handling. | Local validation | Framework | Passed | TRX NotExecuted | | --- | --- | ---: | ---: | | Core, complete project | net10.0 | 5,054 | 88 | | Sessions, complete project | net10.0 | 780 | 610 | | Server aliases, expiry/security and subscriptions | net10.0 | 288 | 0 | | GDS database, paging and live directory calls | net10.0 | 263 | 0 | | Shared Like patterns and bounded evaluation | net10.0 | 109 | 0 | | Subscriptions transfer/durability/service selection | net10.0 | 23 | 1 | | HTTPS WebApi, complete project | net10.0 | 346 | 0 | | InformationModel aliases | net10.0 | 54 | 5 | | Shared Like patterns and bounded evaluation | net48 | 109 | 0 | | Core fault handles, TCP/HTTPS and filters | net48 | 268 | 0 | | Server aliases, expiry/security and subscriptions | net48 | 288 | 0 | These selected runs exited successfully with normal test concurrency: **7,582 passing test executions**. Sessions' console reports 379 skips; the TRX also contains conditional/assumption exclusions, so the table uses the actual 610 NotExecuted records. No skips or weakened assertions were added. The new `EvaluationTimeoutBoundsWorkAndPreservesPatternReuse` and `SharedPatternTimeoutRemainsAnAliasServiceError` tests passed on both targets. **Validation limitation:** two unfiltered Subscriptions runs did not produce a final result and were stopped, including a retry with a hang guard. They are not counted as passing. The isolated failover fixture passed 2/2, and the transfer/durability/service selection above passed. The stall's cause was not established; no unrelated code or test was changed to conceal it. The final `dotnet build UA.slnx -c Release --no-restore -m:1` with `CustomTestTarget=net10.0` succeeded with **0 warnings and 0 errors**. Whitespace verification passed for all hand-reconciled files, and staged source hashes match the validation inputs. These are merge-specific checks, not a new full platform/native/soak acceptance sign-off. Earlier review and acceptance evidence below remains dated to its own commit. ## Previous upstream merge (2026-09-14) Merge `c3e09553e4d1509502d4ba751960d58491bea2cf` integrates `master` through `6313e38a0` (#4478), including the CTT-driven Part 13 aggregate corrections and FindAlias argument validation. The upstream tests and CTT documentation are retained. The single conflict in `AliasNameWildcardMatcher` is reconciled through the branch's shared regex compiler: upstream malformed-pattern rejection and `IsValidPattern` coexist with OPC UA character escaping, whole-string/newline matching, the 100 ms timeout, and service-level error reporting. The upstream ReferenceTypeFilter checks and chronological aggregate behavior are preserved. Local merge verification: | Suite / selection | Framework | Passed | Existing skips | | --- | --- | ---: | ---: | | Server aliases, aggregates and historian | net10.0 | 1,011 | 0 | | Server aliases, aggregates and historian | net48 | 1,011 | 0 | | InformationModel aliases | net10.0 | 54 | 5 | | Features aliases | net10.0 | 11 | 0 | | Client aliases | net10.0 | 28 | 0 | | History aggregate/historical-access consumers | net10.0 | 265 | 0 | All selected tests passed at normal concurrency. The final net10 solution build passed with 0 errors and 0 warnings. The resolved file's whitespace check and the staged diff check passed. These are merge-specific checks, not a new full platform/native/soak acceptance claim. ## Previous upstream merges Integrates `master` through `8e874d073`, including the Client, Types, Core and source-generator audit fixes in #4471, #4473, #4474 and #4475, plus the event queue-size revision from #4480. The merge commits are `150ea0439` (26 conflict resolutions) and `a0e17c07c` (the base update that arrived during validation). The overlapping fixes are reconciled rather than replacing either branch: - **Rejected-certificate retention follows this branch, as explicitly requested:** zero means unlimited history; negative values disable new storage while preserving existing backend trimming behavior (Directory clears prior history; shared key/value leaves it intact). The processor and manager policy are not redefined, and documentation/tests describe the distinction. - Preserve owned certificate snapshots and borrowed validation-core lifetimes alongside upstream cache invalidation, store-first CRL checks and `BadCertificateRevocationUnknown` for unsupported revocation lookup. - Combine exactly-once chunk ownership, receive-loop cancellation, stale-epoch rejection and detach-before-adoption with upstream signature-before-padding verification and secret cleanup. - Preserve pooled `HttpClient` per-request configuration together with bounded response reads and deadlines that remain active through body consumption. Regression cases exercise already-used clients and both timeout precedence cases. - Retain the binary-compatible channel-key constructor and normalize omitted profiles; preserve exact profile filtering and upstream binary-first HTTPS/WSS endpoint ordering. - Preserve monotonic lease expiry and stale-confirmation fencing while adding ordered, non-reentrant leadership notifications outside the state lock. ## Validation Local validation of the conflict-resolution merge: | Suite | Framework | Passed | Existing skips | | --- | --- | ---: | ---: | | Core | net10.0 | 4,940 | 88 | | Client | net10.0 | 2,278 | 0 | | Server | net10.0 | 6,088 | 5 | | Types | net10.0 | 10,394 | 1 | | SourceGeneration.Core | net10.0 | 3,940 | 8 | | SourceGeneration | net10.0 | 185 | 0 | | Core | net48 | 4,855 | 104 | After the subsequent four-file #4480 update, the related Server slice passed 811 tests with 5 existing skips, the real durable queue-size cases passed 6/6, and EventManager's 21 cases passed again after analyzer-only expected-array cleanup. The final `dotnet build UA.slnx -c Release` with `CustomTestTarget=net10.0` succeeded: 0 errors and 4 pre-existing CA2249 warnings in unchanged fuzz-test code. Whitespace verification passed for the resolved files and the follow-up. Merge-specific proof includes `RequestTimeoutRemainsActiveWhileReadingTheOpenApiBodyAsync` on already-used shared clients with either the request or HttpClient timeout shorter, the existing ownership/expiry regressions from both branches, and both branches' retention tests adjusted only to the explicitly chosen zero/negative contract. Earlier acceptance at `1c4a9df6c` passed hosted Linux/macOS CI, CodeQL, image builds and the canonical coverage gate (87.15% line, 77.27% branch, 92.14% patch). Both Framework project matrices, all three Windows native suites (183 cases), and the unfiltered net10 stress project (230 cases, including full 60-minute chaos and 30-minute memory soaks) also passed. Those are **pre-merge results**, not a claim that the full framework/platform/native/soak matrix has been rerun on this merge. The previous acceptance workflow remains separate from this review-feedback operation. Security checks, meaningful assertions, leak detection, default native concurrency, full soak durations and coverage thresholds remain intact. The user-set ready-for-review state is unchanged. No architectural or complete merged-head acceptance sign-off is claimed. ## Related Issues - Fixes #4476 ## 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. - [x] I have addressed **all** PR feedback received.
404 lines
18 KiB
Markdown
404 lines
18 KiB
Markdown
# NodeId Assignment
|
|
|
|
Every node a server creates at runtime needs a NodeId. This document
|
|
describes the mechanisms the stack uses to produce one: the factory
|
|
contract, the identifier formats, how type declarations differ from
|
|
instances, what the source generator emits, and what each NodeManager base
|
|
class does.
|
|
|
|
## Contents
|
|
|
|
- [The contract](#the-contract)
|
|
- [DefaultNodeIdFactory](#defaultnodeidfactory)
|
|
- [Types and instances](#types-and-instances)
|
|
- [What the source generator emits](#what-the-source-generator-emits)
|
|
- [NodeManager behaviour](#nodemanager-behaviour)
|
|
- [Configuring the factory](#configuring-the-factory)
|
|
- [Who forces a fresh identifier](#who-forces-a-fresh-identifier)
|
|
|
|
## The contract
|
|
|
|
`INodeIdFactory` (`Opc.Ua.Types/State/ISystemContext.cs`) has one member:
|
|
|
|
```csharp
|
|
NodeId New(ISystemContext context, NodeState node);
|
|
```
|
|
|
|
It reaches node code through `ISystemContext.NodeIdFactory`. A NodeManager
|
|
publishes itself as that factory in its constructor
|
|
(`SystemContext.NodeIdFactory = this`), so any node-authoring code holding
|
|
the manager's `SystemContext` mints identifiers through the manager. This
|
|
points at the *NodeManager*, not at its factory, so that a subclass
|
|
overriding `New` is still the one node-level code reaches — which is why
|
|
`AsyncCustomNodeManager` and `CustomNodeManager2` do it once for every
|
|
manager that derives from them, and a subclass never repeats it.
|
|
|
|
`IRebasableNodeIdFactory` (`Opc.Ua.Server/NodeManager/IRebasableNodeIdFactory.cs`)
|
|
extends that with what a NodeManager needs and a bare `INodeIdFactory`
|
|
cannot express: which namespace to mint into, which identifier style to
|
|
mint, and an identifier for a node that does not exist yet.
|
|
|
|
```csharp
|
|
NodeIdAssignmentMode Mode { get; }
|
|
ushort DefaultNamespaceIndex { get; }
|
|
IRebasableNodeIdFactory WithDefaultNamespaceIndex(ushort defaultNamespaceIndex);
|
|
IRebasableNodeIdFactory WithMode(NodeIdAssignmentMode mode);
|
|
NodeId NextCounterNodeId();
|
|
NodeId CreateChildNodeId(NodeId parentNodeId, QualifiedName browseName, ushort namespaceIndex, NamespaceTable namespaceUris);
|
|
```
|
|
|
|
The namespace is why this is a contract rather than constructor
|
|
configuration: a namespace belongs to the NodeManager, not to the node, and
|
|
a manager learns its own namespace index only after the server's namespace
|
|
table has been extended — which is later than a factory registered in
|
|
dependency injection was built. Implementations are therefore immutable and
|
|
the `With…` methods return a view, so one registered instance serves
|
|
managers that own different namespaces.
|
|
|
|
`AsyncCustomNodeManager.NodeIdFactory` is typed as this interface, so a
|
|
caller can put its own rule in front of `DefaultNodeIdFactory` by
|
|
decorating it — claim one subtree, delegate the rest — instead of
|
|
overriding `New` on the NodeManager and scattering the identifier rule
|
|
across it.
|
|
|
|
The property is nullable, and null means *do not assign*. A node copy
|
|
deliberately hides the factory from the children it materialises, so code
|
|
that cannot work without one calls
|
|
`ISystemContext.RequireNodeIdFactory()` and fails with a diagnosable
|
|
error rather than a `NullReferenceException`.
|
|
|
|
`New` may be called more than once for the same node — `AssignNodeIds`
|
|
walks a subtree on every create pass — so an implementation has to be
|
|
idempotent or the address space ends up with orphaned references.
|
|
|
|
## DefaultNodeIdFactory
|
|
|
|
`Opc.Ua.Server/NodeManager/DefaultNodeIdFactory.cs` is the stack's
|
|
implementation. It is immutable and safe to share.
|
|
|
|
### The canonical path
|
|
|
|
Every mode except `Counter` derives from one canonical browse path: a
|
|
length-prefixed encoding of the parent identifier and the child browse
|
|
name.
|
|
|
|
```
|
|
v1:10:l:6:s=Root:10:l:6:Group1
|
|
```
|
|
|
|
Each segment carries its own length, so no combination of parent and
|
|
browse name can produce the same path as a different combination. The
|
|
classic `{parentIdentifier}_{browseName}` convention could: `A_B` plus `C`
|
|
and `A` plus `B_C` both gave `A_B_C`. The parent segment keeps the
|
|
parent's identifier type prefix (`s=`, `i=`, `g=`, `b=`), so a string
|
|
parent `"42"` never reads as a numeric parent `42`.
|
|
|
|
Segments are qualified by namespace:
|
|
|
|
| Prefix | Meaning |
|
|
|---|---|
|
|
| `l:` | in the namespace being minted into |
|
|
| `z:` | in namespace 0 |
|
|
| `u:<uri>:` | in another namespace, named by URI |
|
|
| `x:<index>:` | in another namespace that has no URI registered |
|
|
|
|
The URI form is what makes an identifier independent of namespace-table
|
|
ordering. The index form is a fallback for an unregistered namespace; it
|
|
is deliberately distinct so identifiers minted before the namespace was
|
|
registered cannot collide with the ones minted after.
|
|
|
|
The path is scratch. In every mode but `String` it is hashed and thrown
|
|
away, so it is built into a stack buffer — or, when it does not fit, one
|
|
rented from `ArrayPool<char>` — and never becomes a string at all. The
|
|
length is measured and written by one routine, so the length reserved for
|
|
a segment cannot drift from the length written into it.
|
|
|
|
### Modes
|
|
|
|
`NodeIdAssignmentMode` selects how the path becomes an identifier.
|
|
|
|
| Mode | Identifier | Stable across restarts | Collides when browse paths repeat |
|
|
|---|---|---|---|
|
|
| `None` | none — raises `BadConfigurationError` | — | — |
|
|
| `Numeric` | first 32 bits of the path's SHA-256 | yes | yes, plus hash collisions |
|
|
| `String` | the canonical path verbatim | yes | yes |
|
|
| `Guid` | first 128 bits of the path's SHA-256 | yes | yes |
|
|
| `Opaque` | first 128 bits of the path's SHA-256 | yes | yes |
|
|
| `Counter` | sequential number | no | no |
|
|
|
|
`Numeric` is the default: it is the most compact form on the wire and the
|
|
most readable in a client UI.
|
|
|
|
`String` is the only mode that cannot collide for distinct browse paths,
|
|
because it keeps the canonical path whole and two different paths are
|
|
never the same text. The others project the path through SHA-256 and
|
|
inherit that hash's collision probability — negligible for `Guid` and
|
|
`Opaque` at 128 bits, but real for `Numeric` at 32.
|
|
|
|
### Collision detection
|
|
|
|
A 32 bit identifier is a birthday problem: distinct browse paths are
|
|
expected to land on one after roughly 2^16 of them. Left alone that is
|
|
silent damage, because the predefined-node index takes the last writer —
|
|
one node would simply replace the other and the address space would be
|
|
quietly wrong.
|
|
|
|
So the factory records what it mints and raises
|
|
`BadConfigurationError` when a second browse path lands on an identifier
|
|
that a different one already has. The error names the path that was
|
|
refused and the modes that do not have the problem.
|
|
|
|
Minting the same path twice is normal — `AssignNodeIds` walks a subtree
|
|
on every create pass — so the record keeps a witness of the path
|
|
alongside the identifier, and only a *differing* witness is a collision.
|
|
The witness is the tail of the same hash, so two paths would have to
|
|
agree on the identifier and on a further 64 bits before a real collision
|
|
could pass as a re-mint.
|
|
|
|
Three consequences worth knowing:
|
|
|
|
- The record is kept per namespace, because identifiers in different
|
|
namespaces cannot collide, and is shared by every view derived from one
|
|
factory — so two NodeManagers that rebase the registered factory onto
|
|
the same namespace are checked against each other, and draw sequential
|
|
identifiers from one counter rather than from two seeded moments apart.
|
|
Two NodeManagers that each construct a factory of their own instead of
|
|
taking the registered one share nothing, and have no way to.
|
|
- It is never pruned. An identifier handed to a client stays spoken for
|
|
even after the node goes away, so re-minting it for a different path is
|
|
exactly the collision this catches. It costs roughly 50 bytes per
|
|
distinct path minted.
|
|
- It covers what the factory mints, not identifiers a caller assigned
|
|
itself. `String` cannot collide and keeps no record. `Counter` cannot
|
|
collide with itself either, but it mints into the same numeric space a
|
|
hash does, so when checking is asked for it reserves what it hands out —
|
|
otherwise a namespace minting in both modes could issue one identifier
|
|
twice. It steps over a taken value rather than reporting it, because the
|
|
counter is the side free to move.
|
|
|
|
`Counter` is for nodes whose browse paths repeat over time — per-session
|
|
diagnostics objects, inference jobs, rediscovered assets. Its counter
|
|
starts above `0x40000000`, out of reach of any authored model, so a
|
|
long-running server cannot walk a runtime instance onto a type node.
|
|
(The predefined-node index takes the last writer, so such a collision
|
|
silently replaces the type rather than failing.)
|
|
|
|
### Which namespace
|
|
|
|
Every identifier is minted into the factory's `DefaultNamespaceIndex` —
|
|
the NodeManager's own namespace. Neither the parent's namespace nor the
|
|
browse name's is consulted:
|
|
|
|
- a parent can belong to a companion-specification model whose NodeIds
|
|
are fixed by its NodeSet (`DeviceSet` in the DI namespace, for
|
|
instance). A server may not mint identifiers there.
|
|
- a browse name only names the type that declared the child.
|
|
|
|
A NodeManager whose instance namespace is not its first one rebases the
|
|
factory with `WithDefaultNamespaceIndex`.
|
|
|
|
### When an existing NodeId is kept
|
|
|
|
`New` keeps a node's current NodeId when the caller can plausibly have
|
|
chosen it, and mints a fresh one otherwise. Two cases qualify:
|
|
|
|
- the identifier is **already in this NodeManager's namespace**, which no
|
|
other model's NodeIds ever are;
|
|
- the node **stands on its own** rather than hanging off a parent.
|
|
`NodeState.Create` hands a root its NodeId before running the assignment
|
|
pass, so a caller naming a node explicitly arrives this way.
|
|
|
|
Everything else is re-minted, because it reached `New` through
|
|
`AssignNodeIds` walking a subtree copied from a type declaration and still
|
|
carrying that declaration's identifiers. See
|
|
[Who forces a fresh identifier](#who-forces-a-fresh-identifier).
|
|
|
|
### Nodes with no derivable path
|
|
|
|
A node with no browse name, or one hanging off a transient parent that
|
|
has no NodeId (an event instance and its fields), has no stable path.
|
|
Those fall back to the counter whatever mode is selected, so a NodeManager
|
|
never has to special-case them.
|
|
|
|
## Types and instances
|
|
|
|
An information model ships **type declarations**: `PumpType` and every
|
|
child below it, each with a NodeId fixed by the NodeSet. When a server
|
|
creates an instance, `NodeState.CreateInstance` copies that subtree — and
|
|
the copy carries the declaration's NodeIds.
|
|
|
|
Rebasing the copy onto per-instance identifiers is what
|
|
`NodeState.AssignNodeIds` does: it walks the subtree calling
|
|
`INodeIdFactory.New` on each node, collects an old-to-new mapping table,
|
|
and hands it to `UpdateReferenceTargets` so references inside the subtree
|
|
follow.
|
|
|
|
If `New` returns the declaration NodeId unchanged, every instance of the
|
|
type aliases onto the type's own nodes. The failure is silent: the
|
|
predefined-node index replaces rather than rejects, so the ObjectType
|
|
quietly becomes an instance node. Guarding against that is the reason
|
|
`DefaultNodeIdFactory` re-mints anything it did not mint itself, and the
|
|
reason the generator's helpers check the declaration constant before
|
|
rebasing.
|
|
|
|
Type nodes themselves are never passed through `AssignNodeIds`; they are
|
|
loaded from the NodeSet and registered as predefined nodes.
|
|
|
|
## What the source generator emits
|
|
|
|
For each child of a generated type the generator emits helpers that
|
|
materialise it, each with its own NodeId policy.
|
|
|
|
`Create{Type}` / `CreateInstanceOf{Type}Type(parent, browseName)` — builds
|
|
an instance and rebases the subtree through the context's factory.
|
|
|
|
`Add{Child}(context, nodeId = default)`:
|
|
|
|
| Argument | Behaviour |
|
|
|---|---|
|
|
| `nodeId` non-null | the child takes that NodeId, then its descendants are rebased around it |
|
|
| `nodeId` null | rebased through the factory, but **only if** the child still carries its declaration constant |
|
|
|
|
`CreateOrReplace{Child}(context, replacement, assignInstanceNodeIds)`:
|
|
|
|
| Argument | Behaviour |
|
|
|---|---|
|
|
| `assignInstanceNodeIds` false | the child keeps whatever NodeId it has |
|
|
| `assignInstanceNodeIds` true | rebased, but only if the NodeId is null or still the declaration constant |
|
|
|
|
The declaration-constant guard (`nodeState.NodeId.Equals(TypeNodeIdConstant)`)
|
|
is the generator's compile-time way of asking "is this still a
|
|
declaration identifier?". It also means a caller who assigned an
|
|
identifier explicitly keeps it.
|
|
|
|
## NodeManager behaviour
|
|
|
|
### CustomNodeManager2
|
|
|
|
Unchanged, for backwards compatibility:
|
|
|
|
```csharp
|
|
public virtual NodeId New(ISystemContext context, NodeState node)
|
|
{
|
|
return node.NodeId;
|
|
}
|
|
```
|
|
|
|
It mints nothing. A node keeps whatever NodeId it already has, and a node
|
|
with none keeps `NodeId.Null`. Subclasses that need identifiers override
|
|
`New` themselves. Existing servers built on this base see no change in
|
|
behaviour.
|
|
|
|
### AsyncCustomNodeManager
|
|
|
|
`New` delegates to the `NodeIdFactory` property and does nothing else. The
|
|
manager holds no counter of its own. A subclass selects its identifier
|
|
style by assigning that factory in its constructor rather than by
|
|
overriding `New`:
|
|
|
|
```csharp
|
|
NodeIdFactory = NodeIdFactory.WithMode(NodeIdAssignmentMode.Counter);
|
|
```
|
|
|
|
`WithMode` and `WithDefaultNamespaceIndex` both return a copy, so a
|
|
factory shared through dependency injection is never mutated. Assigning a
|
|
factory that names no namespace (a bare `new DefaultNodeIdFactory(mode)`)
|
|
adopts the manager's own namespace; one that names a namespace is left
|
|
alone.
|
|
|
|
The default is `NodeIdAssignmentMode.Numeric`, so a manager that overrides
|
|
nothing gets compact deterministic browse-path identifiers, checked for
|
|
collisions.
|
|
|
|
When `AddNodes` receives no `RequestedNewNodeId`, the preferred identifier may
|
|
already belong to a live node, for example after that node's BrowseName was
|
|
changed. The manager then asks the configured factory for a fresh counter
|
|
identifier, skipping identifiers already registered or reserved by another
|
|
in-flight `AddNodes` request. It reserves the choice before asynchronous
|
|
registration and releases the reservation if registration fails or is canceled.
|
|
The existing node and its references are not replaced. Clients must use the
|
|
`addedNodeId` returned by the service rather than assuming the preferred
|
|
browse-path identifier was available.
|
|
|
|
A client-supplied `RequestedNewNodeId` is never substituted: an occupied or
|
|
reserved identifier returns `BadNodeIdExists`. Duplicate BrowseNames and invalid
|
|
namespaces are still rejected. Factory errors remain visible; a counter factory
|
|
that repeats an occupied identifier reports `BadConfigurationError` instead of
|
|
causing an unbounded retry.
|
|
|
|
### FluentNodeManagerBase and the fluent builders
|
|
|
|
`FluentNodeManagerBase` derives from `AsyncCustomNodeManager` and inherits
|
|
its behaviour. The fluent builders — `AddObject`, `WithProperty`,
|
|
`CreateInstance`, and the alarm and state-machine helpers — mint through
|
|
`FluentNodeRegistration.AssignNodeId`, which routes to the owning
|
|
manager's `New`. They no longer each spell out a
|
|
`{parentIdentifier}_{browseName}` concatenation.
|
|
|
|
Inside a `Configure` delegate the mode can be selected for everything
|
|
created after it:
|
|
|
|
```csharp
|
|
builder.WithNodeIdAssignment(NodeIdAssignmentMode.Guid);
|
|
```
|
|
|
|
Every builder mints through the owning NodeManager's `New`, so there is no
|
|
second identifier shape to fall back to. `WithNodeIdAssignment` needs the
|
|
factory itself, which only an `AsyncCustomNodeManager` carries, so on any
|
|
other NodeManager it raises `BadConfigurationError` rather than quietly
|
|
doing nothing.
|
|
|
|
## Configuring the factory
|
|
|
|
Through dependency injection, once for the whole server:
|
|
|
|
```csharp
|
|
builder.AddNodeIdFactory(NodeIdAssignmentMode.Guid);
|
|
```
|
|
|
|
`DependencyInjectionStandardServer` resolves the registered
|
|
`IRebasableNodeIdFactory`, `StandardServer` threads it into
|
|
`ServerInternalData`, and every `AsyncCustomNodeManager` picks it up
|
|
through `INodeIdFactoryProvider`, rebased onto its own namespace.
|
|
|
|
The registration is keyed on the interface, so an implementation of your
|
|
own — typically a decorator holding a `DefaultNodeIdFactory` — can be
|
|
registered in its place:
|
|
|
|
```csharp
|
|
builder.AddNodeIdFactory(new ReservingNodeIdFactory(new DefaultNodeIdFactory()));
|
|
```
|
|
|
|
## Who forces a fresh identifier
|
|
|
|
For a redundant server set, use
|
|
[`UseReplicaNodeIdentity`](ReplicaNodeIdentity.md) rather than configuring
|
|
independent default factories. It reserves the same shared namespace indexes
|
|
before node-manager construction, binds the assignment policy across replicas,
|
|
and preserves IDs during hydration. Counter/no-path allocation is not
|
|
independently reproducible; only the configured active/passive writer may
|
|
register those shared allocations. Ordinary single-server defaults are unchanged.
|
|
|
|
Two things can be true of a node arriving at `New` with a NodeId already
|
|
set: the caller chose that identifier, or it is a type declaration's
|
|
identifier that `NodeState.CreateInstance` copied onto an instance. The
|
|
factory cannot tell them apart, so the rule is positional rather than
|
|
inferred:
|
|
|
|
- a node that **stands on its own** keeps its NodeId;
|
|
- a node **hanging off a parent** is re-minted, because it reached `New`
|
|
through `AssignNodeIds` walking a copied subtree.
|
|
|
|
A root that must also shed a declaration identifier says so by naming the
|
|
identifier it wants. `ISystemContext.CreateInstance` is the short way to
|
|
do that: it builds the subtree and then rebases it through
|
|
`AssignInstanceNodeId`, which forces a fresh identifier by clearing the
|
|
NodeId and asking again. That is the same path the generated
|
|
`CreateInstanceOf{Type}` helpers take, so hand-written and generated
|
|
authoring agree.
|
|
|
|
Reaching for `NodeState.Create(..., NodeId.Null, ..., assignNodeIds: true)`
|
|
on a generated state object is the trap: the object is born carrying its
|
|
own type's NodeId, `Create` only replaces that when handed an identifier,
|
|
and the result is an instance sitting on the type's node.
|