Skip to content

Commit aadba8b

Browse files
marcschierCopilot
andauthored
Add runtime NodeSet-backed node managers (#3990)
# Description Add a dedicated startup-time runtime NodeSet2 loading path for servers that need to expose information models without source generation. This introduces: - `AddRuntimeNodeSet` DI extensions for single files, grouped sources, and lazy stream providers. - A dedicated `RuntimeNodeSetNodeManagerFactory` and `FluentNodeManagerBase` implementation that remain separate from source-generated NodeManagers and typed fluent builders. - Model ownership validation, `RequiredModel` dependency ordering, parent-child linking, external reference handling, and fail-fast diagnostics. - Untyped `INodeManagerBuilder` code-behind wiring for imported variables, methods, events, alarms, and simulations. - Reuse of the existing default server-side complex-type loading path after the runtime address space is created. - Runtime NodeSet documentation plus NUnit and NativeAOT coverage. The feature intentionally loads NodeSets only during server startup. Live add, reload, or removal after clients connect remains out of scope because it requires separate MasterNodeManager routing, NamespaceArray, model-change, subscription, and complex-type lifecycle design. ## Related Issues - Fixes #3974 ## Validation - Runtime NodeSet tests: 38 passed on `net10.0`. - Runtime NodeSet tests: 38 passed on `net48`. - NativeAOT publish succeeded and the full 107-test executable passed. - Changed C# files pass whitespace verification and the server library builds without new warnings. ## Checklist - [ ] I have signed the [CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf) and read the [CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md) doc. - [x] I have added tests that prove my fix is effective or that my feature works and increased code coverage. - [x] I have added all necessary documentation. - [x] I have verified that my changes do not introduce (new) build or analyzer warnings. - [ ] I ran **all** tests locally using the **UA.slnx** solution against at least .net **framework** and .net **10**, and all passed. - [ ] I fixed **all** failing and flaky tests in the CI pipelines and **all** CodeQL warnings. - [ ] I have addressed **all** PR feedback received. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent c8bafd2 commit aadba8b

17 files changed

Lines changed: 3538 additions & 1 deletion

Docs/ComplexTypes.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -747,6 +747,8 @@ Servers can build the same dynamic stand-in encodeables for the custom DataTypes
747747

748748
DataTypes that are already backed by a compiled, source-generated type are **already registered in the server's `IEncodeableFactory` and used as-is** for encoding and decoding; the server only builds stand-ins for the DataTypes that are still missing from the factory (i.e. those loaded from a NodeSet at runtime).
749749

750+
If you are using [Runtime NodeSets](RuntimeNodeSets.md) (`AddRuntimeNodeSet`) to load NodeSet2 documents at server startup, the server-side complex-type pass runs automatically after all node managers finish building their address spaces — no extra configuration is needed. See [RuntimeNodeSets.md](RuntimeNodeSets.md) for details on the startup-only semantics and stream ownership contract.
751+
750752
### How compiled types reach the factory
751753

752754
Compiled DataTypes are registered explicitly, not by reflection: the OPC UA source generator emits one `Add<Namespace>(this IEncodeableFactoryBuilder)` extension per namespace, and a node manager calls it while it builds its address space (for example `Server.Factory.Builder.AddTestData().Commit()`). Node managers finish starting before `OnNodeManagerStartedAsync` runs, so every source-generated type is already present in `server.Factory` when the complex-type pass executes — `ComplexTypeSystem` finds them via `TryGetType` / `TryGetEncodeableType` and skips them, creating stand-ins only for the remaining runtime-loaded DataTypes.

Docs/DependencyInjection.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ you need finer control.
4040
| `Opc.Ua.Client.ComplexTypes` | `builder.AddComplexTypes()` | `IOpcUaBuilder` |||
4141
| `Opc.Ua.Client.Alarms` (within `Opc.Ua.Client`) | `builder.AddAlarms()` | `IOpcUaBuilder` |||
4242
| `Opc.Ua.Server` | `builder.AddServer(opt => …)` | `IOpcUaServerBuilder` | yes | `OpcUa:Server` |
43+
| `Opc.Ua.Server` (node manager)| `builder.AddNodeManager<T>()` | `IOpcUaServerBuilder` |||
44+
| `Opc.Ua.Server` (runtime NodeSet) | `builder.AddRuntimeNodeSet(…)` | `IOpcUaServerBuilder` |||
4345
| `Opc.Ua.Gds.Client.Common` | `builder.AddGdsClient(opt => …)` | `IGdsClientBuilder` || `OpcUa:Gds:Client` |
4446
| `Opc.Ua.Gds.Server.Common` | `builder.AddGdsServer(opt => …)` | `IGdsServerBuilder` | yes | `OpcUa:Gds:Server` |
4547
| `Opc.Ua.Lds.Server` | `builder.AddLdsServer(opt => …)` | `ILdsServerBuilder` | yes | `OpcUa:Lds` |

Docs/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ Here is a list of available documentation for different topics:
2626
* Source generated [DataTypes] - How to annotate POCO classes and let the source generator generate the `IEncodeable` implementation.
2727
* Runtime [Schema Generation](SchemaGeneration.md) - Produce XSD, OPC Binary (BSD) and JSON Schema (Part 6 Annex C, compact + verbose) for generated encodeable types and dynamically added complex types via the injectable `ISchemaProvider`; schemas are built as object models in code (trimmable, NativeAOT compatible).
2828
* Source generated [NodeManagers](SourceGeneratedNodeManagers.md) - Emit an `AsyncCustomNodeManager` from a model design XML and wire callbacks via the fluent `INodeManagerBuilder` API; supports NativeAOT single-file servers (samples: [MinimalBoilerServer](../Applications/MinimalBoilerServer), [PumpDeviceIntegrationServer](../Applications/PumpDeviceIntegrationServer)). Covers engineering units, property initialisation, alarms, simulation timers, instance creation, NAMUR-style supervision, multi-model composition, and the fluent state-machine builder on top of any `FiniteStateMachineState` subclass. Cross-assembly model references are tracked via the [ModelDependencyAttribute](ModelDependencies.md). Companion-spec packaging — model + server + client library trios — is covered end-to-end by the [Device Integration developer guide](DeviceIntegration.md) using the `Opc.Ua.Di` / `Opc.Ua.Di.Server` / `Opc.Ua.Di.Client` trio as the worked example.
29+
* Runtime [NodeSets](RuntimeNodeSets.md) - Load one or more NodeSet2 XML documents into the server address space at startup without source generation. Covers file and stream sources, dependency ordering, parent-child browse-path resolution, fluent `Configure` callbacks, default namespace inference, and the default complex-type loading path. Use when the XML content changes independently of the server binary or for rapid prototyping.
2930
* [Device Integration (DI) developer guide](DeviceIntegration.md) - End-to-end documentation for the `Opc.Ua.Di*` library trio: fluent `IDeviceBuilder`, device sub-type extensions (`AddSoftware`, `AddBlock`, `AddConfigurableObject`, `AddLifetimeIndication`, `WithSupportInfo`), hosting integration (`AddOpcUaDi` / `ConfigureDevicesFor`), lock service, software-update package store, and client helpers (`DiLockClient`, `DiTopologyClient`, `SoftwareUpdateClient`). Includes a section enumerating supported OPC 10000-100 features against the spec.
3031
* [Alias Names](AliasNames.md) - Full server + client support for the OPC UA Part 17 alias-name model (`AliasNameType`, `AliasNameCategoryType`, `FindAlias`, `FindAliasVerbose`, `AddAliasesToCategory`, `DeleteAliasesFromCategory`, `LastChange`).
3132
* [Alarms and Conditions](AlarmsAndConditions.md) - Full server + client support for OPC UA Part 9. Server-side state types for latched/silenced/out-of-service alarms, alarm groups and suppression engine, alarm rate metrics. Client-side `AlarmClient`, typed alarm event records, fluent `AlarmEventFilterBuilder`, `IAsyncEnumerable` alarm streaming via `AlarmStreamExtensions`.

Docs/RuntimeNodeSets.md

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
# Runtime NodeSets
2+
3+
This guide explains how to load one or more NodeSet2 XML documents into the server's address space at startup without writing a source-generated or hand-coded NodeManager. You configure which files or streams to load; the server imports them in dependency order and registers the resulting nodes.
4+
5+
## When to use the runtime NodeSet path
6+
7+
Use `AddRuntimeNodeSet` when:
8+
9+
- You receive a NodeSet2 XML from a companion-specification vendor and want to host its nodes without regenerating source.
10+
- Your information model changes frequently enough that rebuilding the source-generated manager for every XML update would be disruptive.
11+
- You are prototyping or testing a new NodeSet2 design.
12+
13+
Use the [source-generated path](SourceGeneratedNodeManagers.md) when you want compile-time safety, strong typing, and AOT-safe named constants for every node in your model. The runtime path gives you generic `NodeState` objects and untyped browse-path wiring.
14+
15+
## Startup-only semantics
16+
17+
The runtime NodeSet loader runs **once during server startup**, inside `CreateAddressSpaceAsync`. There is no mechanism to hot-reload, add, or remove NodeSets after the server has accepted its first client connection. If you need to pick up changes to the NodeSet XML, restart the server.
18+
19+
## Quick-start examples
20+
21+
### Single file
22+
23+
```csharp
24+
services.AddOpcUa()
25+
.AddServer(o => { /**/ })
26+
.AddRuntimeNodeSet("Models/MyMachine.NodeSet2.xml");
27+
```
28+
29+
### Single file with a fluent callback
30+
31+
Wire read/write/method handlers on top of the imported nodes using the existing untyped `INodeManagerBuilder` surface:
32+
33+
```csharp
34+
services.AddOpcUa()
35+
.AddServer(o => { /**/ })
36+
.AddRuntimeNodeSet(
37+
"Models/MyMachine.NodeSet2.xml",
38+
nodes =>
39+
{
40+
nodes.Node("Machines/Machine1/Start")
41+
.OnCall(StartMachineAsync);
42+
43+
nodes.Variable<double>("Machines/Machine1/Temperature")
44+
.OnRead(ReadTemperature);
45+
});
46+
```
47+
48+
### Group of dependent NodeSets
49+
50+
Register multiple NodeSet2 sources that depend on each other. The factory resolves the import order automatically from the `RequiredModel` declarations in each document.
51+
52+
```csharp
53+
services.AddOpcUa()
54+
.AddServer(o => { /**/ })
55+
.AddRuntimeNodeSet(options =>
56+
{
57+
options.Sources =
58+
[
59+
RuntimeNodeSetSource.FromFile("Models/Opc.Ua.Di.NodeSet2.xml"),
60+
RuntimeNodeSetSource.FromFile("Models/MyMachine.NodeSet2.xml")
61+
];
62+
options.DefaultNamespaceUri = "urn:example:MyMachine";
63+
options.Configure = nodes =>
64+
{
65+
nodes.Node("Machines/Machine1/Start").OnCall(StartMachineAsync);
66+
};
67+
});
68+
```
69+
70+
### Custom stream source
71+
72+
Use `RuntimeNodeSetSource.FromStream` when you want to open the NodeSet2 document lazily at server startup — for example from a database, a blob store, or an assembly resource. The delegate is called once during startup and must return a fresh, readable `Stream` each time.
73+
74+
```csharp
75+
services.AddOpcUa()
76+
.AddServer(o => { /**/ })
77+
.AddRuntimeNodeSet(options =>
78+
{
79+
options.Sources =
80+
[
81+
RuntimeNodeSetSource.FromStream(
82+
name: "MyMachine",
83+
openStream: ct => OpenNodeSetStreamAsync(ct),
84+
modelNamespaceUris: ["urn:example:MyMachine"])
85+
];
86+
});
87+
```
88+
89+
### Direct factory registration
90+
91+
If you prefer to create the factory manually — for example to inject it into an existing `StandardServer` subclass — construct a `RuntimeNodeSetNodeManagerFactory` directly:
92+
93+
```csharp
94+
var factory = new RuntimeNodeSetNodeManagerFactory(new RuntimeNodeSetOptions
95+
{
96+
Sources = [RuntimeNodeSetSource.FromFile("Models/MyMachine.NodeSet2.xml")]
97+
});
98+
99+
// In a StandardServer subclass constructor:
100+
AddNodeManager(factory);
101+
```
102+
103+
## Stream ownership contract
104+
105+
When `FromStream` is used, the runtime loader calls the `openStream` delegate once while the NodeManager factory is created during server startup and closes the returned stream after deserialization. You must ensure that:
106+
107+
1. Each call to `openStream` returns a **new** stream positioned at the beginning.
108+
2. The stream is **readable** and contains a valid NodeSet2 XML document.
109+
3. You do not close the stream yourself; the factory disposes it after `UANodeSet.Read`.
110+
111+
If the delegate returns `null` or the stream does not contain valid NodeSet2 XML, server startup fails with a clear `InvalidOperationException` that names the source.
112+
113+
## Default namespace for unqualified browse paths
114+
115+
When the `Configure` callback uses browse paths without an explicit `ns=N;` prefix (for example `"Machines/Machine1/Start"`), the runtime loader must know which namespace index to apply for the first path segment. The resolution is:
116+
117+
1. If `RuntimeNodeSetOptions.DefaultNamespaceUri` is set, that URI is used.
118+
2. Otherwise the factory infers the **unique leaf model** — the one model in the loaded group that is not required by any other included source.
119+
3. If inference is ambiguous (multiple leaf models and a `Configure` callback is present), startup fails with an error message that lists the candidates. In this case, set `DefaultNamespaceUri` explicitly.
120+
121+
When no `Configure` callback is registered, `DefaultNamespaceUri` has no effect and may be omitted.
122+
123+
## Dependency sorting
124+
125+
The factory reads the `Models/Model/RequiredModel` entries from each parsed NodeSet document and performs a topological sort (Kahn's algorithm) before importing. Import order guarantees that a required model's nodes are in the address space before any document that depends on them imports its nodes.
126+
127+
Dependencies on models **not included in the group** — for example the OPC UA base namespace or a third-party model hosted by a generated NodeManager — are silently allowed and treated as external. The server resolves cross-manager references through the normal `AddReverseReferencesAsync` mechanism.
128+
129+
Cycles among the included sources cause `InvalidOperationException` at startup with an error message that lists the participating sources.
130+
131+
## Complex types
132+
133+
Runtime complex type loading (structures, enumerations, union types) is **on by default** and requires no extra configuration. After all NodeManagers have built their address spaces, `StandardServer.OnNodeManagerStartedAsync` scans every DataType node whose `DataTypeDefinition` attribute is populated and registers a NativeAOT-safe stand-in encodeable in the server's factory. The same stand-ins are reused for client decode/encode via the OPC UA Part 6 binary protocol.
134+
135+
You can tune this behaviour through the existing `ServerComplexTypeOptions`:
136+
137+
```csharp
138+
services.AddOpcUa()
139+
.AddServer(o => { /**/ })
140+
.AddRuntimeNodeSet("Models/MyMachine.NodeSet2.xml")
141+
.AddComplexTypeSystem(o => o.Enabled = false); // disable if not needed
142+
```
143+
144+
Setting `StandardServer.LoadComplexTypes = false` or disabling the complex type system entirely suppresses the stand-in loading without producing a warning. No second complex-type loading path is introduced by the runtime NodeSet feature.
145+
146+
For a complete description of the server-side complex type system, see [ComplexTypes.md](ComplexTypes.md).
147+
148+
## Comparison with source-generated NodeManagers
149+
150+
| Aspect | Runtime NodeSet (`AddRuntimeNodeSet`) | Source-generated (`[NodeManager]`) |
151+
|--------|--------------------------------------|-------------------------------------|
152+
| Node access in callbacks | Generic `NodeState` / `BaseVariableState` via untyped browse paths | Strongly typed, compiler-checked fluent accessors per node |
153+
| Compilation required on model change | No — reload the XML and restart | Yes — regenerate and rebuild |
154+
| AOT / trimming compatibility | Full (uses the existing `UANodeSet.Read` XmlSerializer path) | Full (generated code is static) |
155+
| Named NodeId constants | Not generated | Generated (`Variables.*`, `Objects.*`, etc.) |
156+
| Multiple namespaces in one manager | Yes — group multiple sources | One namespace per generator run |
157+
| DI registration | `AddRuntimeNodeSet(...)` | `AddNodeManager<TFactory>()` |
158+
| Stream / file input | Files and custom stream factories | MSBuild `AdditionalFiles` only |
159+
160+
Source-generated managers are the recommended path for production code where type safety and compile-time validation matter. Runtime NodeSets are the recommended path for rapid prototyping, model-file delivery scenarios, and cases where the XML content changes independently of the server binary.
161+
162+
## Related documentation
163+
164+
- [Source-Generated NodeManagers](SourceGeneratedNodeManagers.md) — strongly typed alternative.
165+
- [Dependency Injection](DependencyInjection.md)`IOpcUaServerBuilder` and service registration.
166+
- [ComplexTypes.md](ComplexTypes.md) — server-side complex type loading and client-side decoding.

Docs/SourceGeneratedNodeManagers.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -578,6 +578,10 @@ dotnet publish -c Release -r win-x64
578578
`Applications/MinimalBoilerServer` publishes cleanly with **zero AOT/trim
579579
warnings** (~29 MB self-contained EXE).
580580

581+
## Runtime NodeSet alternative
582+
583+
When you want to host a NodeSet2 document without any source generation — for example a companion-spec XML received from a vendor, or a model that changes more frequently than you rebuild — use [AddRuntimeNodeSet](RuntimeNodeSets.md) instead. The runtime path loads a file or stream at server startup, imports nodes in topological dependency order, and exposes them through the same untyped `INodeManagerBuilder` surface as the `Configure` partial above. No rebuild is needed when the XML content changes; restart the server to pick up new content. See [RuntimeNodeSets.md](RuntimeNodeSets.md) for a side-by-side comparison of the two paths.
584+
581585
## Building richer node managers — the fluent extension surface
582586

583587
The Configure callback wires read/write/method/event hooks against

Libraries/Opc.Ua.Server/NugetREADME.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,19 @@ raw-socket `opc.tcp://` binding is built in; reference the
3939
`OPCFoundation.NetStandard.Opc.Ua.Bindings.Https` package to additionally
4040
expose an `https://` endpoint.
4141

42+
To expose a NodeSet2 XML document without generating a NodeManager, register it through the startup-time runtime loader:
43+
44+
```csharp
45+
services.AddOpcUa()
46+
.AddServer(options => { /* server options */ })
47+
.AddRuntimeNodeSet(
48+
"Models/MyModel.NodeSet2.xml",
49+
nodes => nodes.Variable<double>("Machines/Machine1/Temperature")
50+
.OnRead(ReadTemperature));
51+
```
52+
53+
The runtime loader supports grouped file and stream sources, orders included models by `RequiredModel`, and uses the server's default runtime complex-type support. See the [Runtime NodeSets guide](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/Docs/RuntimeNodeSets.md).
54+
4255
## Target frameworks
4356

4457
`net472`, `net48`, `netstandard2.1`, `net8.0`, `net9.0`,

0 commit comments

Comments
 (0)