Skip to content

WoT Binding and WoT Connectivity 1.1 - #4093

Draft
marcschier wants to merge 113 commits into
masterfrom
marcschier/wot-binding-xregistry
Draft

WoT Binding and WoT Connectivity 1.1#4093
marcschier wants to merge 113 commits into
masterfrom
marcschier/wot-binding-xregistry

Conversation

@marcschier

@marcschier marcschier commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

Status and dependencies

Draft proof implementation.

What this proves

Lossless NodeSet2 ↔ WoT conversion

  • Adds a dependency-light, AOT-safe WoT lexical model and converters to Opc.Ua.Types.
  • Preserves unrecognized/unmapped JSON-LD values as digest-protected RFC 6901 residue entries in standard NodeSet Extensions.
  • Emits the deterministic, versioned uav:nodes projection only when readable-only reverse conversion does not reproduce the complete UANodeSet.
  • Supports WhenRequired, Always, and Never preservation modes. uav:nodeSet is emitted only for explicit byte archival or a demonstrated fallback, and completeness tests use Never.
  • Reconstructs from the complete native projection, an explicit envelope, or ordinary binding-authored TD/TM documents.
  • Includes bounded resolver contracts, structured diagnostics and canonical NodeSet comparison.

WoT source generation

  • Accepts .tm.json, .td.json, .tm.jsonld, .td.jsonld and explicitly opted-in .jsonld AdditionalFiles.
  • Converts them to an in-memory UANodeSet and uses the existing direct NodeSet code-generation pipeline.
  • Adds stable MODELGEN030MODELGEN034 diagnostics for parsing, conversion and virtual-path collisions.

Live NodeManager shadow reload

  • Extends [Server] Add live NodeManager lifecycle support #4015 with ShadowReloadAsync.
  • Adds ImmediateReloadAsync as the alternative policy: it atomically routes new requests to the replacement, invalidates affected non-durable monitored items with BadNodeIdUnknown, detaches disposed owner graphs, and rejects durable subscriptions before commit.
  • Routes new service requests to the replacement generation while existing monitored items remain on the retired generation.
  • Disposes retired generations after subscriptions drain.
  • Keeps the existing fail-closed ReloadAsync/RemoveAsync behavior unchanged.

Registry-first WoT Connectivity 1.1

  • Compiles the pinned xRegistry model and the single combined WoT-Con 1.1 NodeSet. The combined model contains every published 1.02 node plus the additive registry model in the same namespace, so the old ModelDesign is not generated a second time.
  • Adds a stable, injectable TD/TM registry with in-memory and atomic file-backed stores, versions, epochs, desired/active state, persisted labels and bounds.
  • Exposes browseable xRegistry/WoT-Con 1.1 group/resource/file nodes, FileTransfer, CRUD, label, validation, enable/default-version and detailed Refresh methods.
  • Builds TM/TD dependency closures and materializes them through runtime NodeSet lifecycle Add/ShadowReload/Remove.
  • Resolves document dependencies only from the registry snapshot (or an injected federation layer); arbitrary external document URLs are not fetched.
  • Exposes WotProjectionRetirementPolicy so deployments select graceful or immediate replacement.
  • Retains invalid versions and the previous active projection, emits resource/refresh events, and reuses lifecycle model-change notifications.
  • Adapts the existing v1.02 CreateAsset/WoTFile/CloseAndUpdate workflow to the registry without changing published signatures or clients.

Replaceable protocol binding code-behind

Adds a dependency-light planner/binder project for:

  • HTTP
  • CoAP
  • MQTT
  • Modbus TCP
  • BACnet
  • PROFINET
  • LoRaWAN
  • OPC UA

Concrete optional executor projects cover HTTP, MQTT, Modbus TCP and OPC UA. The OPC UA executor supports Read, Write, native data-change observation, Method invocation and Event subscription, including portable nsu= NodeIds.

BACnet, PROFINET, LoRaWAN and CoAP are planner/validation-only in this proof and report their non-executable capability explicitly.

Review remediation

The follow-up review fixes are included:

  • shadow-retired data monitored items remain modifiable, transferable and deletable through their owning NodeManager and trigger prompt retired-generation cleanup;
  • mqtts enables TLS, defaults to 8883, resolves credentials/trust and fails closed;
  • Modbus validates the full 16-bit address range and maps function-only forms exactly;
  • HTTP redirects are explicit, bounded and cannot forward credentials across origins;
  • registry mutations become visible only after an atomic durable store commit;
  • all generated typed WoT event fields are populated;
  • uav:nodeSet.sha256 is mandatory and malformed XML becomes structured diagnostics;
  • one resolver context enforces aggregate limits across the full conversion;
  • event affordances carry uav:eventType, persisted identities use portable nsu= ExpandedNodeIds, and HasComponent subtypes round-trip exactly;
  • generated contexts bind ua and deterministic nsN prefixes; ReferenceType model names are emitted directly in link rel, with uav:refId as definitive identifier, and name/NodeId conflicts are rejected;
  • compact mapToTypeName / congruentTypeName hints remain paired with definitive identifiers, while arbitrary instance targets remain ExpandedNodeIds;
  • the non-published uav:typedReference / refTypeName draft surface is removed;
  • readable BrowseNames/paths prefer @context prefixes (with URI-qualified QualifiedNames also accepted), authored IDs are retained during synthesis, and the proof omits uav:nodes when the semantic mapping alone is equivalent;
  • native conversion covers root/model/alias/node fields, all eight NodeClasses, values, DataType definitions, permissions, translations and XML extensions without an envelope;
  • unknown JSON-LD members survive repeated WoT ↔ NodeSet cycles through bounded residue with stable context/link/affordance selectors;
  • observable / observeproperty is documented as advertised binding support, not a restriction on core UA monitorability.
  • registry FileType write-open/write/write-position/commit paths enforce the management SecureChannel policy while read-only access remains deployment-selectable;
  • committed reload cleanup failures carry the authoritative replacement registration so the coordinator cannot retain a stale handle, and committed model/namespace notifications are still attempted;
  • immediate retirement is serialized with monitored-item and durable-subscription mutations, emits one terminal status for Reporting/Sampling data items, clears event/aggregate queues, suppresses resend, and releases old owner graphs.

Compatibility

  • Existing OPC 10100-1 v1.02 model/NodeIds/method signatures remain unchanged.
  • Existing IWotAssetProviderFactory integrations remain supported.
  • Concrete transport dependencies are isolated from Opc.Ua.WotCon.Server.
  • Planner/core projects retain the repository's legacy target-framework matrix; concrete executors target modern .NET.

Commit structure

  1. Add shadow NodeManager reload support
  2. Add lossless WoT conversion and source generation
  3. Add injectable WoT protocol bindings
  4. Add registry-first WoT Connectivity runtime
  5. Align WoT Connectivity proof to combined 1.1 spec model
  6. Align WoT converter with portable Binding identity
  7. Make WoT NodeSet conversion native-first
  8. Address WoT model concept review feedback
  9. Address WoT mapping review feedback
  10. Prefer context prefixes for WoT QualifiedNames
  11. Implement selectable WoT projection retirement
  12. Consolidate WoT bindings and add WoT-driven aggregation sample
  13. Apply code style and analyzer fixes to WoT bindings branch
  14. Wire the DI NodeManager fluent builder DataType resolver
  15. Address PR review feedback on WoT docs and sample layout
  16. Raise PR patch coverage from 70% to 91% with unit and integration tests
  17. Fix net48 test failures and remove dead WoT mapping code
  18. Merge copilot/3993-live-node-manager-lifecycle into marcschier/wot-binding-xregistry
  19. Adopt the merged xRegistry implementation and packages
  20. Derive WotRegistryClient from the shared XRegistryClient
  21. Make subscription transfer effects transactional
  22. Add a WoT blob store implementing the xRegistry resource store contract
  23. Let a WoT registry keep its document bytes in an injected resource store
  24. Add WoT materialization and polling extension points (#4108, #4109, #4110)
  25. Address code review findings and PR feedback

Validation

  • full Opc.Ua.Types.Tests net10.0 and net48: passed
  • source-generation suite, including all WoT AdditionalFile cases: passed
  • full Opc.Ua.WotCon.Tests and Opc.Ua.WotCon.Bindings.Tests net10.0: passed
  • lifecycle-focused net10.0 and net48 suites: passed
  • registry transaction and typed-event integration suites: passed
  • full dotnet build UA.slnx -c Release: passed
  • NativeAOT win-x64 publish/run: 115 passed
  • full converter/source-generator/legacy-TFM validation completed before the final rebase
  • pinned xRegistry/WoT-Con 1.1 models match the specification repository
  • immediate/graceful lifecycle, registry FileType security and committed-reload recovery tests: passed
  • WoT materialization policy tests: 22 passed on net10.0 and net48
  • affected Opc.Ua.Server / Opc.Ua.WotCon.Server builds on net10.0 and net472: passed with 0 warnings
  • git diff --check: clean

Documentation layout

  • docs/WoTProtocolBindings.md + docs/WoTBindingDevelopment.md are merged into a single docs/WotBindings.md, ordered as Part 1 (the bindings that ship today) and Part 2 (adding your own binding).
  • docs/WoTAggregationSample.md moved to samples/WotCon/README.md, next to the WoT samples it documents.
  • The runtime-loaded WoT documents live in samples/WotCon/AggregationClient/Documents: the client is what uploads them into the server, and the aggregation server has no dependency on them.
  • The aggregation guide now states that the registry accepts documents in any order, that an incomplete dependency closure is never partially published, and how progress surfaces under AutoRefresh = true versus the sample's AutoRefresh = false.

Test coverage

Codecov reported 70.39 % patch coverage against the 80 % codecov/patch gate. Tests were added across Opc.Ua.Types/Wot, Opc.Ua.WotCon.Server, Opc.Ua.WotCon.Bindings*, Opc.Ua.Server and the WoT source generator, raising measured patch coverage to 91.2 % (10 750 of 11 782 changed lines). No production behaviour was changed to achieve it; the only production edit was removing the unreachable WotNodeSetConverter.ToCompactModelName helper.

Suite totals on net10.0, all warning-free: Opc.Ua.Types.Tests 8 390, Opc.Ua.WotCon.Tests 823, Opc.Ua.WotCon.Bindings.Tests 464, Opc.Ua.SourceGeneration.Tests 94, Opc.Ua.Server.Tests 3 851. On net48: Opc.Ua.Types.Tests 8 383 and Opc.Ua.WotCon.Tests 823.

Base branch merge

The live NodeManager lifecycle work (#4094) has landed in master and has been merged in; this PR is now based on master. The upstream NodeManager lifecycle interface consolidation was adopted (IMonitoredItemLifecycleIDetachableMonitoredItem, INodeManagerMonitoredItemTracker folded into ISubscriptionMonitoredItemLifecycle, ExecuteMonitoredItemMutationAsync moved to IDynamicNodeManagerHost) while retaining this branch's shadow/immediate reload support; INodeManagerMonitoredItemRetirementTracker now lives in its own file to match the new one-interface-per-file layout.

Reported issues addressed

Raised by @barnstee while evaluating whether UA Edge Translator can replace its hand-written WoT-Connectivity node manager with this library. All four target the deprecated OPC 10100-1 v1.02 asset surface; the answers live in the registry-first materialization runtime and Opc.Ua.WotCon.Bindings that this PR adds.

Review-round remediation (commit 3ea717089)

An implementation review raised 11 findings (5 High, 6 Medium) and 12 review threads were open. All are addressed.

Correctness / robustness

  • Content-addressed blobs are verified and skipped rather than rewritten on every commit, and blob writes stage to a temp file and publish through an atomic replace, so an interrupted write can no longer leave a partial blob that the unchanged manifest still references.
  • Added an IAtomicFileReplace capability implemented by LocalFileSystem and VirtualFileSystem, rather than a new IFileSystem member: that would break external implementers and is not expressible as a default interface member on netstandard2.0. Blob storage now fails fast instead of silently degrading to a destructive write.
  • The registry accessor borrows the container-wide ManagedSession instead of disposing it, so a failed ForServerAsync no longer closes the session other client facades are still using.
  • ModbusTcpClient reconnects a faulted connection on the next transaction; backoff stays in PollingWotSubscription so there is only one retry loop.
  • NodeSetComparer enforces MaxXmlDepth, which was validated but never read, so deeply nested XML reports a diagnostic instead of overflowing the stack.
  • WotMaterializationCoordinator enforces MaxResolverDocumentBytes, and decides namespace knownness from the live materialized closure because namespace-table entries outlive the NodeManagers that registered them.
  • Bulk load validates the group kind on cache hits as well as misses; GetOrCreateGroup normalizes names the way the registry service does; the resource output map is read under its gate.

API

  • Removed System.Nullable wrappers around INullable types across the WoT and registry surface, including TrySelectProjectionRoot.
  • The WoT resolver contracts are now async. The converter pre-resolves through a shared reference enumeration into a WotThingCatalog, and the synchronous core only performs lookups — so the Roslyn source generator stays synchronous and no sync-over-async is introduced.
  • Completed ExtensionObject.TryGetValue to decode through the codecs and reduced the Variant structure helpers to delegation, since the context-taking API already existed.
  • NodeSetComparer.CompareXml takes ReadOnlySpan<byte> instead of byte[].

Layout

  • WotCon samples grouped under samples/WotCon/ with the Wot prefix dropped from folder, project, assembly and namespace names.
  • NodeSetComparer moved next to the other NodeSet code in Opc.Ua.Export.
  • MemoryWotBinding moved out of the shipping bindings library into the aggregation sample and the test project.
  • Removed Sync-WotConModels.ps1 (belongs in the drafts repository); renamed the pinned NodeSet2 files to WotCon casing; reverted a blank-line-only change in Session.cs.

Server lifecycle

  • Request admission and lifecycle-waiter tracking are factored out of RequestManager into an optional registerable extension. With no extension registered the behaviour is exactly master's. The AsyncLocal scope nesting stays in RequestManager: it fixes scope propagation for the pre-existing scopes rather than adding a feature.
  • StandardServer disposal is documented and idempotent.
  • The Subscription/Session and server-shutdown threads asked for investigation and proposals before changes, so those are analysed and answered on the threads rather than rewritten here.

Validation: full UA.slnx builds clean on net10.0 (0 warnings) and net48 (0 errors). Suite totals on net10.0: Opc.Ua.Types.Tests 8 408, Opc.Ua.Server.Tests 3 978, Opc.Ua.WotCon.Tests 920, Opc.Ua.WotCon.Bindings.Tests 512, Opc.Ua.SourceGeneration.Core.Tests 3 743, Opc.Ua.SourceGeneration.Tests 112, Opc.Ua.XRegistry.Tests 171, Opc.Ua.Di.Tests 310, Opc.Ua.ISA95.Tests 137.

Server shutdown hardening (commit 46e5e5f2d)

Follow-up to the StandardServer.cs review thread, which asked for a thorough review of the server lifecycle handling with proposals before merge. The structural proposals remain open on that thread and no refactor was done; what landed here are the concrete defects that are wrong under any of those proposals:

  • The shutdown task slot is reserved under m_shutdownCoordinationLock while ShutdownServerInternalAsync now starts outside it. Previously every statement before its first await executed under the lock. ActiveTask is still published inside the lock, so concurrent callers keep joining a single shutdown.
  • The deferred retry is bounded by a maximum retry count and an overall time budget; it previously retried indefinitely as long as cleanup progress kept increasing.
  • Terminal shutdown failures surface through ServerError and a source-generated log instead of only being recorded on the shutdown state.
  • The deferred observer routes its exceptions rather than leaving them unobserved.

Final node-manager cleanup deliberately keeps CancellationToken.None: cancelling it would leak retired lifecycle state, node managers and the server semaphore after request admission has already been closed permanently. Those call sites are now commented to make the intent explicit.

Opc.Ua.Server.Tests: 3 980 passing, 0 warnings.

Open review threads

Three threads are intentionally left unresolved because they ask for a joint decision rather than a specific change, and each has a full written analysis posted:

  • StandardServer.cs — three ranked structural proposals for the shutdown coordinator, awaiting a direction.
  • Subscription.cs — a four-capability inventory of what this PR adds versus what master already supports, with a keep/refactor/revert recommendation per capability.
  • IMonitoredItem.cs — answered: immediate generation retirement is the one scenario master's detach/park/recover lifecycle cannot express.

@marcschier marcschier changed the title Draft: prove registry-first WoT Binding and Connectivity 1.1 stack DRAFT: WoT Binding and WoT Connectivity 1.1 Jul 25, 2026
marcschier and others added 16 commits July 25, 2026 08:48
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 29378426-2a98-41ee-9a6d-050af5b1141f
…ver limits

- Make uav:nodeSet.sha256 mandatory: a missing, malformed, or
  mismatched digest now produces a structured InvalidDigest/
  DigestMismatch diagnostic and no NodeSet is returned, instead of
  silently skipping integrity verification when the field was absent.
- Catch XML/deserialization failures from UANodeSet.Read and report
  them as a WotDiagnosticCode.MalformedNodeSet diagnostic instead of
  letting the exception escape the converter.
- Create exactly one WotResolutionContext per top-level conversion in
  WotNodeSetConverter.ToNodeSetCore, seeded from the converter options
  (depth, max documents, cumulative bytes), and thread that single
  context through link/thing resolution. Removes the previous
  per-link context ??= new WotResolutionContext() fallback in
  TryResolveTargetNodeId, which reset cycle/limit tracking for every
  link instead of applying bounds across the whole conversion.
- Seed WotNodeSetDocumentConverter's resolution context from its
  configured options instead of resolver defaults.
- Add WotNodeSetConverterOptions.MaxResolverDocuments/
  MaxResolverDocumentBytes/MaxResolverTotalBytes (aligned with
  WotResolverOptions defaults) plus ToResolverOptions() and
  validation, so aggregate resolver limits are configurable through
  the same options object used for the rest of the conversion.
- Add tests: missing/malformed digest, digest mismatch, malformed
  NodeSet XML without throwing, unsupported vs. accepted (base64)
  encoding, multi-link aggregate document/byte limits sharing one
  context, and sibling-link cycle detection that doesn't block
  unrelated links.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
After a ShadowReload the retired generation is removed from the routing
table but still owns the monitored items created on it. Modify, Delete,
SetMonitoringMode and Transfer previously iterated only the visible
routing-table managers, so a same-namespace replacement generation would
claim (and fail on) the retired items - returning BadMonitoredItemIdInvalid
and leaving the retired generation's items undrained and its address space
never disposed until another lifecycle operation or shutdown.

Dispatch each data monitored item to its recorded owning NodeManager
(grouped by owner, each owner offered only its own indices) so a retired
generation services its own items and a same-namespace replacement cannot
cross-claim them. Event/all-event handling and result indexing are
unchanged.

Trigger prompt retired-generation cleanup when the last item drains: the
master notifies the lifecycle from the Delete path when an owner is no
longer registered, and the lifecycle runs cleanup on a background task
(request execution context suppressed, coordinated via the lifecycle
semaphore) so nothing is disposed inside the request callback. Existing
fail-closed Reload/Remove behaviour and the deferred-cleanup fallback are
preserved.

Add tests proving that after ShadowReload old data items can be modified,
disabled/re-enabled, transferred and deleted on the retired generation,
new reads use the replacement, old notifications keep flowing, and the
retired generation is disposed after the final drain without another
lifecycle operation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…rects)

MQTT: mqtts now enables TLS and defaults to port 8883, resolving username /
password, TLS client certificate and trust anchors through the credential
provider. A declared security scheme fails closed when unresolved, and
username / password never downgrades to plaintext unless explicitly allowed.
plain mqtt stays explicit (port 1883).

Modbus: the planner enforces address 0-65535 and the address+quantity-1 range,
maps function-only forms exactly onto codes 1,2,3,4,5,6,15,16 (mnemonic or
numeric) and rejects op/function and entity/function mismatches. The executor
re-validates the range before the ushort/byte casts.

HTTP: the executor-owned HttpClient disables automatic redirects and applies a
bounded, origin-aware redirect policy that strips custom header/query
credentials across origins, refuses loops, non-http(s) schemes and https->http
downgrades, and caps hops. A caller-supplied client with a credential-bearing
form fails closed unless CallerClientHandlesRedirectSafety is set.

Adds planner, executor and E2E regression tests and updates the binding docs
and options. WotCredential gains runtime-only client certificate / trust
material.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Registry mutations now persist through a transactional store commit
before publishing the snapshot or raising Changed, and the generated
WoT V2 event types are fully populated from the coordinator arguments.

- IWotRegistryStore: replace the per-entity Upsert/Remove methods with a
  single atomic CommitAsync(snapshot). LoadAsync returns only the
  committed generation.
- FileWotRegistryStore: stage content-addressed version blobs durably,
  then switch a single manifest.json pointer with an atomic replace, so a
  crash never exposes a half-written generation; Load reads only the
  committed manifest.
- InMemoryWotRegistryStore: keep the committed snapshot behind an atomic
  reference switch (same failure semantics).
- WotRegistryService: commit durably before publishing m_snapshot or
  raising Changed for every CRUD/default/enabled/labels and projection
  path. On failure Current stays previous, no event is raised, a retry
  re-attempts persistence and a restart never sees partial data.
- WotRegistryNodeManager: populate every generated WoT event field
  (resource identity/kind, generation, phase/outcome, validation outcome,
  load state/failed node/reason, binding uri, refresh summary/request id)
  from WotMaterializationEventArgs via SetChildValue.
- Tests: fault-injection transaction tests (atomicity, no-event, retry,
  restart) and real EventFilter integration tests that verify the typed
  fields are delivered through the running server notifier chain.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Regenerate the Opc.Ua.WotCon model from the single revised
Opc.Ua.WoTCon NodeSet2, which incorporates the published OPC 10100-1
v1.02 model (NodeIds 1..172, deprecated) plus the additive registry
nodes (64000+) in one namespace (http://opcfoundation.org/UA/WoT-Con/,
version 1.1.0), replacing the separate 1.02 ModelDesign and the
Opc.Ua.WotCon.V2 companion model.

Model / build:
- Pin Opc.Ua.WoTCon.NodeSet2.xml/.csv (+ xRegistry) and drop the
  V2-named artifacts; update Sync-WotConModels.ps1 mappings/docs.
- Opc.Ua.WotCon.csproj: stop compiling WotConnection.xml/csv; compile
  the combined NodeSet once as Opc.Ua.WotCon (name WotCon, model URI
  WoT-Con/). Legacy 1.02 sources kept under Design/ for reference only.

Server / tests / docs:
- Replace all Opc.Ua.WotCon.V2 namespace/type references with the
  combined Opc.Ua.WotCon generated types (AddOpcUaWotCon, ObjectIds,
  BrowseNames, DataTypeIds, ...); remove user-visible V2 wording and
  rename the registry config section to OpcUa:WotConRegistry:Server.
- Decide model-node ownership cleanly via WotConModelPartition: the
  registry NodeManager owns {WotCon, xRegistry} and loads the registry
  slice (>=64000); the legacy asset NodeManager loads the incorporated
  1.02 slice (<64000). Neither claims the same static node twice.
- Add CombinedModelPreservationTests proving exact 1.02 NodeIds, typed
  method state/results and the generated client API are preserved, and
  that the registry types now coexist in Opc.Ua.WotCon.

Source generator (NodeSet2 -> ModelDesign) fixes exposed by generating
the richer combined model with typed method arguments:
- Map placeholder BrowseNames "<Name>" to "Name_Placeholder".
- Reuse an existing explicit method-type declaration instead of
  synthesizing a colliding duplicate, and carry the concrete method's
  arguments onto it.
- Register the standard encodeable types (Argument) on the value decoder
  so NodeSet2 method argument lists actually decode (was silently empty).
- Disambiguate a by-ref output argument whose name matches an input.
- Skip synthesizing method types for base-namespace (inherited) methods
  and for standalone method-type declarations.

Deprecated 1.02 nodes remain usable and all 1.02 tests pass unchanged
(only mechanical Ua.DataTypeIds qualification where the new combined
DataTypeIds shadows Core).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
marcschier and others added 3 commits July 29, 2026 07:29
char.IsAsciiLetterOrDigit is .NET 7+, but Opc.Ua.WotCon.Server also targets net48
and netstandard2.0, so the file-name safety check is now an explicit ASCII range
test. char.IsLetterOrDigit would have been the wrong fix: it accepts non-ASCII
letters that are not safe in a file name.

Docs: a "Keeping the document bytes in a shared store" section in
docs/WoTConnectivity.md covering WotRegistryServerOptions.ResourceStore, the DI
precedence, why the historical {root}/{digest}.bin layout is preserved (no on-disk
migration), and why the registry keeps manifest atomicity while only the bytes
move. docs/XRegistry.md now cites WotBlobResourceStore as a worked example of
implementing the store contract for a domain registry.

Validation: net10.0 WotCon 902, XRegistry 171, WotCon.Bindings 505, Types 8390,
Server 3966 (only the known pre-existing local PKI flake); net48 WotCon 902.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 9e6a5abf-3299-4cd1-9855-010fedbf0ad8
…4110)

Addresses three gaps @barnstee found while evaluating whether UA Edge Translator
can replace its hand-written WoT-Connectivity node manager with this library.

#4110 polling and backoff

PollingWotSubscription already gave poll-only bindings a shared loop, but it only
reacted to thrown exceptions: a binding that maps a failure onto a bad StatusCode
returned a failed WotReadResult without throwing, so nothing was notified (the
variable silently kept its last good value) and the loop kept polling an offline
device once per interval.

The poll callback now reports health instead. HTTP and Modbus surface the mapped
bad status and report the poll unhealthy, and consecutive unhealthy polls back off
through IChannelReconnectPolicy - the abstraction the stack already uses for
channel reconnects, rather than a second bespoke backoff. Default is
ExponentialBackoffChannelReconnectPolicy (500 ms doubling to 30 s); RetryPolicy on
Http/ModbusWotBindingOptions overrides it. Backing off never polls faster than the
configured interval, the first healthy poll resets it, and a policy that reports
"stop retrying" ends the loop rather than spinning.

Per-affordance interval without inventing vocabulary

The issue asked for a polling interval on the tag. Rather than add a uav: term -
that vocabulary is OPC-owned, so extending it is a specification decision - this
uses the standard term the protocol binding already defines: modv:pollingTime,
milliseconds, per the W3C Modbus binding (distinct from modv:timeout, a request
timeout). It compiles onto WotOperationDescriptor.PollInterval and wins over the
executor's ObserveInterval. HTTP has no standard polling term and keeps using its
option.

#4109 custom DataTypes

IWotNodeSetContributor runs once per resource after conversion and before any
variable is created, and may add nodes to the converted NodeSet - the hook for
StructureTypes that have no NodeSet to import because they belong to one
controller program (Studio 5000 / TIA Portal / TwinCAT UDTs). That ordering is the
point: a uav:mapByFieldPath mapping only resolves once its structured DataType
exists. The declarative path already worked - the native projection carries
DataType nodes with their DataTypeDefinition - so this adds only the programmatic
one. IWotDocumentConverter is now also resolved from DI; it was a constructor
parameter that could not actually be overridden through the builder.

#4108 NodeSet resolution - abstraction only

IWotNodeSetResolver is consulted for every namespace a converted document requires
but neither declares nor finds on the server, recursing into whatever it returns.
Resolved models are prepended to the projection sources so they materialize before
the document that requires them, and unresolved namespaces are reported rather
than silently dropped.

NOT a full fix for #4108: no UA Cloud Library implementation ships. Resolving a
namespace means reaching out to a catalogue, which is a new external HTTP
dependency and a deployment decision, so the library takes no dependency on one.
The uav:nodeSet envelope (embedding the NodeSet in the Thing Description) and the
existing RuntimeNodeSet loading remain the paths that need no resolver.

All three seams are optional; registering none leaves behaviour unchanged.

Tests: backoff engages and resets, a policy that gives up stops the loop,
modv:pollingTime is carried and validated, contributors run after conversion and
reach the projection, a resolved dependency is projected first, and an unresolved
namespace is reported. WotCon 908, WotCon.Bindings 511; solution builds
warning-free.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 9e6a5abf-3299-4cd1-9855-010fedbf0ad8
Comment thread samples/WotCon/README.md
Comment thread src/Opc.Ua.Server/Server/RequestManager.cs
Comment thread src/Opc.Ua.Server/Server/StandardServer.cs
Comment thread src/Opc.Ua.Server/Server/StandardServer.cs
Comment thread src/Opc.Ua.Server/Subscription/MonitoredItem/IMonitoredItem.cs
Comment thread src/Opc.Ua.Types/BuiltIn/Variant.cs Outdated
Comment thread src/Opc.Ua.Types/Wot/NodeSetComparer.cs Outdated
Comment thread tests/Opc.Ua.WotCon.Tests/Support/MemoryWotBinding.cs
Comment thread src/Opc.Ua.WotCon/Design/Sync-WotConModels.ps1 Outdated
Comment thread src/Opc.Ua.WotCon/Design/Opc.Ua.WotCon.NodeSet2.xml
marcschier and others added 2 commits July 30, 2026 01:08
Fixes the 11 findings from the implementation review and the 12 open review
threads on this PR.

Review findings
- FileWotRegistryStore/WotBlobResourceStore: content-addressed blobs are now
  verified and skipped instead of rewritten on every commit, and blob writes
  stage to a temp file and publish through an atomic replace. An interrupted
  write can no longer leave a partial blob that the unchanged manifest still
  references.
- Added the IAtomicFileReplace capability to IFileSystem implementations rather
  than a new interface member, which would break external implementers and is
  not expressible as a default interface member on netstandard2.0. Blob storage
  now fails fast instead of silently degrading to a destructive write.
- The WotCon registry client accessor borrows the container-wide ManagedSession
  instead of disposing it, so a failed ForServerAsync no longer closes the
  session other client facades are still using.
- ModbusTcpClient reconnects a faulted connection on the next transaction;
  backoff stays in PollingWotSubscription so there is only one retry loop.
- NodeSetComparer enforces MaxXmlDepth, which was validated but never read, so
  deeply nested XML reports a diagnostic instead of overflowing the stack.
- WotMaterializationCoordinator enforces MaxResolverDocumentBytes and decides
  namespace knownness from the live materialized closure, because namespace
  table entries outlive the NodeManagers that registered them.
- Bulk load validates the group kind on cache hits as well as misses.
- GetOrCreateGroup normalizes names the way the registry service does, so a name
  that slugifies differently no longer reports Created on every call.
- WotRegistryProjection reads the resource output map under its gate.
- Removed System.Nullable wrappers around INullable types across the WoT and
  registry surface, including TrySelectProjectionRoot.
- The WoT resolver contracts are async. The converter pre-resolves through a
  shared reference enumeration into WotThingCatalog and the synchronous core
  only performs lookups, so the Roslyn generator stays synchronous and no
  sync-over-async is introduced.

Review threads
- Grouped the WotCon samples under samples/WotCon and dropped the Wot prefix
  from folder, project, assembly and namespace names.
- Moved NodeSetComparer next to the other NodeSet code in Opc.Ua.Export.
- Completed ExtensionObject.TryGetValue to decode through the codecs and reduced
  the Variant structure helpers to delegation, since the context-taking API
  already existed.
- Factored request admission and lifecycle waiter tracking out of RequestManager
  into an optional registerable extension. The AsyncLocal scope nesting stays,
  as it fixes scope propagation rather than adding a feature.
- Documented and made StandardServer disposal idempotent.
- Moved MemoryWotBinding out of the shipping bindings library into the
  aggregation sample and the test project.
- Removed Sync-WotConModels.ps1, which belongs in the drafts repository.
- Renamed the pinned NodeSet2 files to WotCon casing.
- Reverted a blank-line-only change in Session.cs.

CompareXml now takes ReadOnlySpan<byte> instead of byte[].

The Subscription/Session and server shutdown threads asked for investigation
before changes, so those are analysed and answered on the threads rather than
rewritten here.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 9e6a5abf-3299-4cd1-9855-010fedbf0ad8
Addresses the concrete defects found while reviewing the server lifecycle
handling in StandardServer, per the review request for a thorough review of
that code. This is deliberately not a structural refactor: the three proposals
for restructuring the coordinator are on the review thread and are still open.

- The shutdown task slot is now reserved under the coordination lock while the
  shutdown itself starts outside it. Previously every statement before the
  first await in ShutdownServerInternalAsync ran under m_shutdownCoordinationLock.
  Concurrent callers still join a single shutdown because ActiveTask is
  published inside the lock.
- The deferred retry is bounded by a maximum retry count and an overall time
  budget. It previously retried indefinitely as long as cleanup progress kept
  increasing. Exceeding either bound takes the existing terminal path.
- Terminal shutdown failures surface through ServerError and a source-generated
  log rather than only being recorded on the shutdown state.
- The deferred observer routes its exceptions instead of leaving them
  unobserved.

Final node-manager cleanup keeps using CancellationToken.None. That is
intentional rather than an oversight: cancelling it would leak retired
lifecycle state, node managers and the server semaphore after request
admission has already been closed permanently. The call sites are now
commented to say so.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 9e6a5abf-3299-4cd1-9855-010fedbf0ad8
Comment thread docs/WotBindings.md Outdated
Comment thread docs/WotBindings.md Outdated
Comment thread src/Opc.Ua.Server/Server/RequestManagerLifecycleExtension.cs
Comment thread src/Opc.Ua.Server/Subscription/MonitoredItem/MonitoredItem.cs
Comment thread src/Opc.Ua.Server/Subscription/SessionPublishQueue.cs
Comment thread src/Opc.Ua.Server/Subscription/SessionPublishQueue.cs
Comment thread src/Opc.Ua.Server/Subscription/Subscription.cs
Comment thread src/Opc.Ua.Server/Subscription/Subscription.cs
Comment thread src/Opc.Ua.Server/Subscription/Subscription.cs Outdated
Comment thread src/Opc.Ua.Server/Subscription/SubscriptionManager.cs Outdated
marcschier and others added 2 commits July 30, 2026 18:09
Fixes the review comments raised on the server lifecycle, publish queue and
documentation surface of this PR.

No sync over async
- SubscriptionManager now implements IAsyncDisposable. DisposeAsync signals
  shutdown, cancels the publish delay, awaits the publish and condition-refresh
  workers and waits the semaphore asynchronously. The synchronous Dispose no
  longer blocks; it only signals and releases resources. Disposal is idempotent.
  The previous code awaited the worker tasks with GetAwaiter().GetResult() and
  blocked on the semaphore from inside Dispose, which this PR had introduced.
- ServerInternalData disposes the subscription manager through the asynchronous
  path.
- WotResourceFileManager.Close routes through OnCallAsync so the commit is
  awaited end to end instead of being blocked on.

The two remaining blocking waits in SubscriptionManager and the blocking
dispose in WotConnectivityNodeManager are unchanged, having been confirmed as
pre-existing. The two adapter calls in SyncNodeManagerAdapter are also
unchanged: that type exists to bridge asynchronous NodeManagers to the legacy
synchronous INodeManager contract, and the surrounding transfer call already
does the same thing.

Publish queue locking
- SessionPublishQueue probes the concurrent dictionary without a lock on the
  common paths of PublishCompleted and Requeue, and takes the lock only to
  mutate queue entry state or a transfer claim, where the multi-step invariant
  genuinely requires it. The locks around Add, Remove, ContainsSubscription and
  the current-entry snapshot are gone.

Documentation
- Internal types and the public and internal members of internal types across
  the PR now carry summary documentation, including the source-generated log
  message classes.
- Call chains keep the method name on the line of the instance where that stays
  within the line length limit.
- WotBindings.md drops the Part N section prefixes, gains a table of contents
  and describes the current project layout, including the moved samples, the
  binding that no longer ships in the library and the asynchronous resolver
  contracts.
- Subscription.PublishTimerExpired explains, with a reference to the
  specification, why a transfer in progress suspends publishing.

The new publish queue concurrency tests are added under the tests directory
rather than a second directory differing only in case, and they avoid APIs that
do not exist on the legacy target frameworks.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 9e6a5abf-3299-4cd1-9855-010fedbf0ad8
The core server test project referenced Opc.Ua.WotCon.Server, which inverted
the layering: Opc.Ua.Server itself knows nothing about WoT, but its tests
pulled in the whole WoT stack. That prevents the server changes from being
reviewed or merged independently of the WoT ones.

The tests in question were two kinds tangled together. The genuine WoT
integration tests move to Opc.Ua.WotCon.Tests, where the stack they exercise
lives. The server lifecycle behaviour they also happened to cover is already
covered against plain NodeSet payloads by RuntimeNodeSetLifecycleTests, which
needs no WoT types at all.

The project reference and the remaining WoT usings are gone from the server
test project. No production code changed and the combined pass count is
unchanged.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 9e6a5abf-3299-4cd1-9855-010fedbf0ad8
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants