diff --git a/Applications/MinimalBoilerServer/BoilerNodeManager.Configure.cs b/Applications/MinimalBoilerServer/BoilerNodeManager.Configure.cs
index 42d1123211..ac6389f23d 100644
--- a/Applications/MinimalBoilerServer/BoilerNodeManager.Configure.cs
+++ b/Applications/MinimalBoilerServer/BoilerNodeManager.Configure.cs
@@ -28,7 +28,10 @@
* ======================================================================*/
using System;
+using System.Collections.Generic;
+using System.Runtime.CompilerServices;
using System.Threading;
+using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Opc.Ua;
using Opc.Ua.Server.Fluent;
@@ -52,30 +55,62 @@ namespace Boiler
/// after base.CreateAddressSpace has materialized the
/// predefined Boiler instance, so all browse paths into the
/// Boilers/Boiler #1 sub-tree are addressable here.
+ ///
+ /// The wiring below is intentionally a mix of the four addressing
+ /// styles — string browse path, absolute ,
+ /// type-definition lookup, and the new typed
+ /// surface — to demonstrate
+ /// that the legacy and source-generator-friendly APIs interoperate.
+ ///
///
[NodeManager(NamespaceUri = "http://opcfoundation.org/UA/Boiler/")]
public partial class BoilerNodeManager
{
private long m_drumLevelTicks;
private long m_pipeFlowTicks;
+ private long m_inputFlowTicks;
+ private long m_drumHeartbeatTicks;
partial void Configure(INodeManagerBuilder builder)
{
- // Addressing by browse-path — works against the deployment
- // tree produced by the generator from the NodeSet2.
+ // (1) Legacy browse-path addressing with the lower-level
+ // ref-Variant callback. Use this when you need full control
+ // over the StatusCode / SourceTimestamp returned per read.
builder
.Node("Boilers/Boiler #1/DrumX001/LIX001/Output")
.OnRead(GenerateDrumLevel);
- // Addressing by absolute NodeId — use the generator's
- // strongly-typed identifier table instead of a magic string.
+ // (2) Absolute NodeId addressing using the strongly-typed
+ // identifier table generated from the NodeSet2.
builder
.Node(ExpandedNodeId.ToNodeId(
VariableIds.Boilers_Boiler__1_PipeX001_FTX001_Output,
Server.NamespaceUris))
.OnRead(GeneratePipeFlow);
- // Addressing by TypeDefinitionId — robust for well-known
+ // (3) New typed IVariableBuilder via the absolute NodeId
+ // table — the simple Func overload removes the
+ // ref-Variant boilerplate from the lambda and runs through
+ // the same sync read path as (1).
+ builder
+ .Variable(ExpandedNodeId.ToNodeId(
+ VariableIds.Boilers_Boiler__1_FCX001_Measurement,
+ Server.NamespaceUris))
+ .OnRead(GenerateInputFlow);
+
+ // (4) New typed async IVariableBuilder overload — the
+ // handler runs OUTSIDE the NodeState lock (lock-released
+ // semantics in BaseVariableState.ReadAttributeAsync), so the
+ // lambda may freely await without tying up a thread-pool
+ // thread. Hooked to the second pipe's flow output to show
+ // the routing end-to-end through AsyncCustomNodeManager.
+ builder
+ .Variable(ExpandedNodeId.ToNodeId(
+ VariableIds.Boilers_Boiler__1_PipeX002_FTX002_Output,
+ Server.NamespaceUris))
+ .OnRead(GenerateOutputFlowAsync);
+
+ // (5) TypeDefinitionId addressing — robust for well-known
// singletons, independent of browse-path layout.
builder
.NodeFromTypeId(ExpandedNodeId.ToNodeId(ObjectTypeIds.BoilerType, Server.NamespaceUris))
@@ -86,6 +121,112 @@ partial void Configure(INodeManagerBuilder builder)
node.BrowseName));
}
+ ///
+ /// Source-generator-emitted typed builder partial. The fluent
+ /// surface here walks the model's predefined-instance tree
+ /// directly: each segment is a generated property whose return
+ /// type is the typed wrapper for the next node. Browse paths,
+ /// NodeIds, and namespace-index lookups are eliminated at the
+ /// callsite — IntelliSense surfaces every legal child, and
+ /// typos are compile-time errors.
+ ///
+ ///
+ /// This partial coexists with ;
+ /// the generated CreateAddressSpaceAsync override invokes
+ /// both. Wiring the same node from both partials is illegal and
+ /// will throw at startup, so the targets here are deliberately
+ /// disjoint from the ones in the non-typed partial above.
+ ///
+ partial void Configure(IBoilerNodeManagerBuilder builder)
+ {
+ // (6) Typed traversal — the LCX001 level controller measurement
+ // is reached via generated accessors with no string paths or
+ // NodeIds in sight. The Func handler is the same shape
+ // as wiring (3) but the resolution is fully type-checked.
+ builder.Boilers.Boiler__1.LCX001.Measurement
+ .OnRead(GenerateLevelControlMeasurement);
+
+ // (7) Typed traversal of a method node — the Halt method is
+ // bound to an async lambda. The generator emits the typed
+ // OnCall(Func) overload that
+ // erases the (ISystemContext, MethodState, NodeId, ArrayOf,
+ // List, CancellationToken)/ServiceResult plumbing entirely.
+ builder.Boilers.Boiler__1.Simulation.Halt
+ .OnCall(HaltSimulationAsync);
+
+ // (8) Event publish source — the source-generated typed
+ // wrapper for DrumX001 exposes Publish because the
+ // model declares EventNotifier=SubscribeToEvents on this
+ // node. The factory iterator runs lazily: the registry
+ // activates it the first time a client subscribes to events
+ // on the drum (or any ancestor that walks via inverse
+ // HasNotifier/HasEventSource references) and cancels it once
+ // the last interested monitored item disappears. The
+ // registry auto-populates EventId/EventType/Time/SourceNode
+ // so the iterator only fills the user-meaningful fields.
+ builder.Boilers.Boiler__1.DrumX001
+ .Publish(GenerateDrumHeartbeatAsync);
+ }
+
+ private long m_levelMeasurementTicks;
+
+ private double GenerateLevelControlMeasurement()
+ {
+ long t = Interlocked.Increment(ref m_levelMeasurementTicks);
+ return 50.0 + (10.0 * Math.Cos(t * 0.05));
+ }
+
+ private async ValueTask HaltSimulationAsync(CancellationToken cancellationToken)
+ {
+ // Token-aware async work to demonstrate the end-to-end async
+ // method call path through AsyncCustomNodeManager.CallAsync.
+ await Task.Yield();
+ cancellationToken.ThrowIfCancellationRequested();
+ Server.Telemetry.CreateLogger()
+ .LogInformation("Boiler simulation halted.");
+ }
+
+ ///
+ /// Lazily emits a synthetic heartbeat
+ /// every 500ms while at least one client is monitoring events on
+ /// the drum notifier. Cancellation tears the iterator down on the
+ /// last unsubscribe (or on manager disposal). The registry fills
+ /// in EventId, EventType, SourceNode,
+ /// SourceName, Time, and ReceiveTime on the
+ /// way out, so the iterator only sets the user-meaningful fields.
+ ///
+ private async IAsyncEnumerable GenerateDrumHeartbeatAsync(
+ BaseObjectState notifier,
+ ISystemContext context,
+ [EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ while (!cancellationToken.IsCancellationRequested)
+ {
+ Task delay = Task.Delay(
+ TimeSpan.FromMilliseconds(500), cancellationToken);
+ try
+ {
+ await delay.ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ yield break;
+ }
+
+ long sequence = Interlocked.Increment(ref m_drumHeartbeatTicks);
+ var ev = new BaseEventState(parent: notifier);
+ ev.Severity = PropertyState.With(
+ ev, (ushort)EventSeverity.Medium);
+ ev.Message = PropertyState.With(
+ ev,
+ new LocalizedText(string.Format(
+ System.Globalization.CultureInfo.InvariantCulture,
+ "Drum heartbeat #{0}",
+ sequence)));
+ yield return ev;
+ }
+ }
+
private ServiceResult GenerateDrumLevel(
ISystemContext context,
NodeState node,
@@ -120,5 +261,23 @@ private ServiceResult GeneratePipeFlow(
timestamp = DateTimeUtc.Now;
return ServiceResult.Good;
}
+
+ private double GenerateInputFlow()
+ {
+ long t = Interlocked.Increment(ref m_inputFlowTicks);
+ return 80.0 + (15.0 * Math.Sin(t * 0.09));
+ }
+
+ private async ValueTask GenerateOutputFlowAsync(CancellationToken cancellationToken)
+ {
+ // Token-aware no-op delay simulates an out-of-process source
+ // (a database round-trip, a remote sensor read, etc.) without
+ // pulling in a real I/O dependency. Cancellation correctness
+ // here flows all the way back to AsyncCustomNodeManager.ReadAsync.
+ await Task.Yield();
+ cancellationToken.ThrowIfCancellationRequested();
+ long t = Interlocked.Increment(ref m_pipeFlowTicks);
+ return 105.0 + (25.0 * Math.Cos(t * 0.07));
+ }
}
}
diff --git a/Applications/MinimalCalcServer/CalcNodeManager.Configure.cs b/Applications/MinimalCalcServer/CalcNodeManager.Configure.cs
new file mode 100644
index 0000000000..9449d2c296
--- /dev/null
+++ b/Applications/MinimalCalcServer/CalcNodeManager.Configure.cs
@@ -0,0 +1,96 @@
+/* ========================================================================
+ * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System.Threading;
+using System.Threading.Tasks;
+using Opc.Ua;
+using Opc.Ua.Server.Fluent;
+
+namespace Calc
+{
+ ///
+ /// Source-generated CustomNodeManager2 for the calculator
+ /// sample. The [NodeManager] attribute opts this partial class
+ /// in to source generation: the generator emits the sibling partial
+ /// that owns the predefined-node load and calls back into the
+ /// Configure partials in CalcNodeManager.Configure.cs.
+ ///
+ ///
+ /// The calculator model intentionally exposes three method shapes —
+ /// sync int+int→int, async double+double→double, and reference-typed
+ /// string+string→string — to exercise the generator's typed
+ /// OnCall input-unpack and output-box code paths end-to-end.
+ /// The wiring lives in the second partial; this file holds only the
+ /// attribute-bearing class declaration so that the source generator
+ /// pipeline ([NodeManager] + AdditionalFiles NodeSet2)
+ /// can be reasoned about in one glance.
+ ///
+ [NodeManager(NamespaceUri = "http://opcfoundation.org/UA/Calc/")]
+ public partial class CalcNodeManager
+ {
+ partial void Configure(INodeManagerBuilder builder)
+ {
+ // Intentionally empty. Kept to mirror the Boiler sample and
+ // demonstrate that the typed and non-typed Configure partials
+ // coexist on the same class — the generated address-space
+ // bootstrap invokes both. The calculator sample wires every
+ // node through the typed surface in
+ // Configure(ICalcNodeManagerBuilder).
+ }
+
+ partial void Configure(ICalcNodeManagerBuilder builder)
+ {
+ // Sync int+int→int — exercises Variant.TryGetValue on
+ // each input arg and Variant.From on the boxed result.
+ builder.Calculator.Add
+ .OnCall((int a, int b) => a + b);
+
+ // Async double+double→double — exercises the typed async
+ // OnCall overload (Func>) end-to-end through
+ // AsyncCustomNodeManager.CallAsync, plus Variant.From
+ // on the boxed result.
+ builder.Calculator.Multiply
+ .OnCall(async (double x, double y, CancellationToken ct) =>
+ {
+ await Task.Yield();
+ ct.ThrowIfCancellationRequested();
+ return x * y;
+ });
+
+ // Sync string+string→string — exercises reference-type
+ // marshalling on both inputs and the output. Coalesces null
+ // inputs to empty so the handler is well-defined when a
+ // client passes a null Variant in either slot.
+ builder.Calculator.Concat
+ .OnCall((string left, string right) =>
+ (left ?? string.Empty) + (right ?? string.Empty));
+ }
+ }
+}
diff --git a/Applications/MinimalCalcServer/MinimalCalcServer.csproj b/Applications/MinimalCalcServer/MinimalCalcServer.csproj
new file mode 100644
index 0000000000..d357d0ea36
--- /dev/null
+++ b/Applications/MinimalCalcServer/MinimalCalcServer.csproj
@@ -0,0 +1,37 @@
+
+
+ net10.0
+ Exe
+ MinimalCalcServer
+ MinimalCalcServer
+ OPC Foundation
+ Self-contained .NET console OPC UA server demonstrating source-generated NodeManagers + the typed fluent OnCall surface for methods with arguments. Native AOT compatible.
+ Copyright © 2004-2025 OPC Foundation, Inc
+ Calc
+ enable
+ false
+ $(NoWarn);CA1822
+ true
+
+
+
+
+
+
+
+ Analyzer
+ false
+
+
+
+
+
+
+
+
+ http://opcfoundation.org/UA/Calc/
+ Calc
+ Calc
+
+
+
diff --git a/Applications/MinimalCalcServer/Model/Calc.xml b/Applications/MinimalCalcServer/Model/Calc.xml
new file mode 100644
index 0000000000..5899501979
--- /dev/null
+++ b/Applications/MinimalCalcServer/Model/Calc.xml
@@ -0,0 +1,114 @@
+
+
+
+
+
+ http://opcfoundation.org/UA/Calc/
+ http://opcfoundation.org/UA/
+
+
+
+
+ Returns a + b.
+
+
+ First addend.
+
+
+ Second addend.
+
+
+
+
+ Computed sum.
+
+
+
+
+
+ Returns a * b. Wired with the asynchronous typed OnCall overload.
+
+
+ First factor.
+
+
+ Second factor.
+
+
+
+
+ Computed product.
+
+
+
+
+
+ Returns left + right.
+
+
+ Left-hand string.
+
+
+ Right-hand string.
+
+
+
+
+ Concatenated value.
+
+
+
+
+
+
+ An object type that exposes Add/Multiply/Concat methods exercising the typed fluent OnCall surface.
+
+
+
+
+
+
+
+
+
+ A calculator exposing Add, Multiply and Concat under the Objects folder.
+
+
+ ua:Organizes
+ ua:ObjectsFolder
+
+
+
+
+
diff --git a/Applications/MinimalCalcServer/Program.cs b/Applications/MinimalCalcServer/Program.cs
new file mode 100644
index 0000000000..d2eaca35dd
--- /dev/null
+++ b/Applications/MinimalCalcServer/Program.cs
@@ -0,0 +1,52 @@
+/* ========================================================================
+ * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using Opc.Ua.Server.Hosting;
+
+HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);
+
+builder.Logging.ClearProviders();
+builder.Logging.AddConsole();
+
+int port = int.TryParse(builder.Configuration["port"], out int p) ? p : 62542;
+
+builder.Services
+ .AddOpcUaServer(o =>
+ {
+ o.ApplicationName = "MinimalCalcServer";
+ o.ApplicationUri = "urn:localhost:OPCFoundation:MinimalCalcServer";
+ o.ProductUri = "uri:opcfoundation.org:MinimalCalcServer";
+ o.AutoAcceptUntrustedCertificates = true;
+ o.EndpointUrls.Add($"opc.tcp://localhost:{port}/MinimalCalcServer");
+ })
+ .AddNodeManager();
+
+await builder.Build().RunAsync().ConfigureAwait(false);
diff --git a/Applications/MinimalCalcServer/Properties/AssemblyInfo.cs b/Applications/MinimalCalcServer/Properties/AssemblyInfo.cs
new file mode 100644
index 0000000000..2b9848014c
--- /dev/null
+++ b/Applications/MinimalCalcServer/Properties/AssemblyInfo.cs
@@ -0,0 +1,32 @@
+/* ========================================================================
+ * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System;
+
+[assembly: CLSCompliant(false)]
diff --git a/Docs/SourceGeneratedNodeManagers.md b/Docs/SourceGeneratedNodeManagers.md
index ec65974138..6965402084 100644
--- a/Docs/SourceGeneratedNodeManagers.md
+++ b/Docs/SourceGeneratedNodeManagers.md
@@ -202,6 +202,278 @@ against the in-memory predefined-node tree. There is no reflection, no
`Activator.CreateInstance`, no `Expression.Compile` — the whole pipeline
is NativeAOT-safe.
+## Typed model-traversal — the `Configure(I{Manager}NodeManagerBuilder)` partial
+
+Alongside the string/NodeId/TypeId addressing surface above, the
+generator emits a **second** `Configure` partial whose builder parameter
+exposes one IntelliSense-aware accessor per predefined instance, child,
+variable and method in the model. Every wiring site becomes a chain of
+properties — typos are compile-time errors, not startup-time
+`ServiceResultException`s.
+
+```csharp
+public partial class BoilerNodeManager
+{
+ // Untyped Configure remains available for nodes outside the model
+ // (e.g. dynamic instances, foreign-namespace nodes, or just to keep
+ // hand-written wiring side-by-side with typed wiring).
+ partial void Configure(INodeManagerBuilder builder)
+ {
+ builder
+ .Node("Boilers/Boiler #1/DrumX001/LIX001/Output")
+ .OnRead(GenerateDrumLevel);
+ }
+
+ // Typed Configure: every accessor below is a generated property
+ // resolved against the model. The compiler enforces both the path
+ // shape AND the value type of every leaf.
+ partial void Configure(IBoilerNodeManagerBuilder builder)
+ {
+ // Variable: typed Func handler — the generator removed
+ // the ref-Variant boilerplate.
+ builder.Boilers.Boiler__1.LCX001.Measurement
+ .OnRead(GenerateLevelMeasurement);
+
+ // Variable, async: routes through BaseVariableState.ReadAttributeAsync
+ // outside the lock so the lambda may freely await.
+ builder.Boilers.Boiler__1.PipeX002.FTX002.Output
+ .OnRead(GenerateOutputFlowAsync);
+
+ // Method, async: typed OnCall(Func)
+ // overload. Bind sync Action variants the same way.
+ builder.Boilers.Boiler__1.Simulation.Halt
+ .OnCall(HaltSimulationAsync);
+ }
+}
+```
+
+Both partials are optional and both run; wiring the same node from
+both is illegal and throws at startup. Choose whichever shape best fits
+each call site — typed for everything declared in the model, untyped
+for everything else.
+
+### What the generator emits per model
+
+For a model with `N` ObjectTypes and `M` predefined instances/children
+the generator emits, into a single `{Manager}.FluentBuilders.g.cs`:
+
+- `internal interface I{Manager}NodeManagerBuilder : INodeManagerBuilder`
+ — one accessor per top-level predefined instance.
+- `internal sealed class {Manager}NodeManagerTypedBuilder` — proxy that
+ forwards `INodeManagerBuilder` members to the runtime builder while
+ surfacing the typed accessors.
+- One `internal sealed class` per instance node — whose properties map
+ to typed `IVariableBuilder`, child wrapper instances, and
+ method wrappers.
+- One `internal sealed class` per method — exposing typed
+ `OnCall(Func)` and async
+ `OnCall(Func>)`
+ overloads when the model declares input/output arguments
+ (the generator handles `Variant.TryGetValue` unpacking and
+ `Variant.From` boxing — see [Methods with arguments](#methods-with-arguments--typed-oncall-overloads)).
+ Argument-less methods keep the no-arg `OnCall(Action)` /
+ `OnCall(Func)` overloads.
+
+All emitted types are `internal sealed` because `Configure` is a
+private partial — the surface never escapes the assembly. Child
+accessors resolve namespace indices lazily through
+`ISystemContext.NamespaceUris.GetIndexOrAppend(...)` so the wrappers
+work regardless of the namespace-table order at runtime.
+
+### Methods with arguments — typed `OnCall` overloads
+
+When a model method declares input or output arguments the generator
+emits **typed `OnCall` overloads** that bind directly to the user
+handler's parameters and return value. Inputs are unboxed via
+`Variant.TryGetValue(out T)`, the boxed result is written back
+through `Variant.From(value)`, and `BadInvalidArgument` /
+`BadArgumentsMissing` is returned when the wire shape does not match
+the declared signature — none of which the user has to spell out.
+
+Two overloads are emitted per method:
+
+- `OnCall(Func handler)` — synchronous
+ dispatch through `MethodState.OnCallMethod2`.
+- `OnCall(Func>
+ handler)` — async dispatch through `MethodState.OnCallMethod2Async`,
+ awaited inside `AsyncCustomNodeManager.CallAsync` so the lambda may
+ freely `await`.
+
+Methods with multiple output arguments are bound to a `ValueTuple`
+return — slot `i` is written from `__r.Item{i+1}`. Methods with no
+return value (action-only) keep the existing `OnCall(Action)` /
+`OnCall(Func)` overloads.
+
+```csharp
+[NodeManager(NamespaceUri = "http://opcfoundation.org/UA/Calc/")]
+public partial class CalcNodeManager
+{
+ partial void Configure(ICalcNodeManagerBuilder builder)
+ {
+ // Sync int+int → int. The generator unpacks each Variant
+ // through Variant.TryGetValue and boxes the result back
+ // through Variant.From.
+ builder.Calculator.Add
+ .OnCall((int a, int b) => a + b);
+
+ // Async double+double → double. The CancellationToken is
+ // forwarded by AsyncCustomNodeManager.CallAsync so the
+ // handler may freely await and honour cancellation.
+ builder.Calculator.Multiply
+ .OnCall(async (double x, double y, CancellationToken ct) =>
+ {
+ await Task.Yield();
+ ct.ThrowIfCancellationRequested();
+ return x * y;
+ });
+
+ // Sync string+string → string. Reference-typed inputs and
+ // return values use the same Variant.TryGetValue / Variant.From
+ // path; the handler can null-coalesce safely because a missing
+ // input is reported as BadInvalidArgument before the lambda
+ // ever runs.
+ builder.Calculator.Concat
+ .OnCall((string left, string right) =>
+ (left ?? string.Empty) + (right ?? string.Empty));
+ }
+}
+```
+
+The end-to-end sample lives in
+`Applications/MinimalCalcServer/` (model in `Model/Calc.xml`, wiring
+in `CalcNodeManager.Configure.cs`). The companion AOT round-trip tests
+in `Tests/Opc.Ua.Aot.Tests/CalculatorNodeManagerAotTests.cs` exercise
+each shape over a real `Session.CallAsync(...)`.
+
+## Event sources — typed `Publish` on notifier wrappers
+
+Beyond reads, writes and method calls, the fluent API lets callers
+register an `IAsyncEnumerable` against any notifier object so
+events flow into the standard `NodeState.ReportEvent` path
+automatically. The runtime owns the entire lifecycle: it starts the
+iterator the first time a client subscribes to events on the notifier
+(or any ancestor that walks via inverse `HasNotifier` /
+`HasEventSource` references), cancels it when the last interested
+monitored item disappears, and disposes it on manager teardown.
+
+Generated managers derive from `Opc.Ua.Server.Fluent.FluentNodeManagerBase`
+out of the box, so wiring is one call:
+
+```csharp
+partial void Configure(IBoilerNodeManagerBuilder builder)
+{
+ // The DrumX001 wrapper exposes Publish because the model
+ // declares EventNotifier=SubscribeToEvents on the node. Lazy by
+ // default — the iterator only runs while a client is monitoring.
+ builder.Boilers.Boiler__1.DrumX001
+ .Publish(GenerateDrumHeartbeatAsync);
+}
+
+private async IAsyncEnumerable GenerateDrumHeartbeatAsync(
+ BaseObjectState notifier,
+ ISystemContext context,
+ [EnumeratorCancellation] CancellationToken cancellationToken)
+{
+ while (!cancellationToken.IsCancellationRequested)
+ {
+ try
+ {
+ await Task.Delay(TimeSpan.FromMilliseconds(500), cancellationToken)
+ .ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) { yield break; }
+
+ var ev = new BaseEventState(parent: notifier);
+ ev.Severity = PropertyState.With(
+ ev, (ushort)EventSeverity.Medium);
+ ev.Message = PropertyState.With(
+ ev, new LocalizedText("Drum heartbeat"));
+ yield return ev;
+ }
+}
+```
+
+The runtime auto-populates `EventId`, `EventType`, `SourceNode`,
+`SourceName` (browse name of the notifier), `Time`, `ReceiveTime`,
+`Severity` (Medium when 0) and `Message` (empty `LocalizedText` when
+unset) on the way out, so the iterator only sets the user-meaningful
+fields.
+
+### Where the typed overload appears
+
+The generator emits `Publish` on a wrapper **only** when the
+underlying node qualifies as an event source:
+
+- `ObjectDesign.SupportsEvents == true` (i.e. the model declares
+ `EventNotifier=SubscribeToEvents`, `HasNotifier`, or
+ `HasEventSource`), or
+- The node has a forward `GeneratesEvent` / `AlwaysGeneratesEvent`
+ reference.
+
+`TEvent` is constrained to `BaseEventState` — pass any subtype that
+fits the model's event hierarchy. For nodes outside the model, or
+hand-written managers, the same `Publish` extension
+is available directly on `INodeBuilder` where
+`TNotifier : BaseObjectState`.
+
+### Two registration shapes
+
+```csharp
+// Direct stream — registry uses the same instance for every activation.
+builder.Boilers.Boiler__1.DrumX001
+ .Publish(channel.Reader.ReadAllAsync(default));
+
+// Factory — registry calls the factory each time a client subscribes,
+// so the iterator can capture the live notifier / context / token.
+builder.Boilers.Boiler__1.DrumX001
+ .Publish(
+ (notifier, context, ct) => GenerateAsync(notifier, context, ct));
+```
+
+### Tuning lifecycle with `EventPublishOptions`
+
+```csharp
+builder.Boilers.Boiler__1.DrumX001
+ .Publish(GenerateDrumHeartbeatAsync,
+ new EventPublishOptions
+ {
+ // Keep iterator running even with no monitored items.
+ AlwaysOn = false,
+
+ // Skip default population of EventId / EventType / Time /
+ // ReceiveTime / SourceNode / SourceName / Severity / Message.
+ SkipDefaultPopulation = false,
+
+ // Register the notifier as a server-wide root notifier so
+ // clients can monitor events on the Server object itself.
+ RegisterAsRootNotifier = true,
+
+ // Bound how long the registry waits for the iterator to
+ // honour cancellation on deactivation.
+ CancellationTimeout = TimeSpan.FromSeconds(5),
+
+ // Optional fault-handler invoked when the iterator throws.
+ OnError = (notifier, exception, context) => { /* log */ }
+ });
+```
+
+### Hand-written node managers
+
+Managers that don't use the source generator can opt in by deriving
+from `Opc.Ua.Server.Fluent.FluentNodeManagerBase` and calling
+`AttachToBuilder(builder)` from inside their address-space-build
+callback. Once attached, all `Publish` extensions resolve against the
+manager's registry exactly as for generated managers.
+
+The end-to-end sample lives in
+`Applications/MinimalBoilerServer/BoilerNodeManager.Configure.cs`
+(wiring `GenerateDrumHeartbeatAsync` on the drum). The companion AOT
+round-trip test in
+`Tests/Opc.Ua.Aot.Tests/PublishedEventsAotTests.cs` subscribes a
+real client `MonitoredItem` with an `EventFilter` and asserts the
+heartbeats arrive end-to-end under NativeAOT constraints (no JIT, no
+reflection).
+
## Single-file `Program.cs` — what it looks like
The shipping `Opc.Ua.Server.Hosting.AddOpcUaServer(...)` extension wires the
@@ -308,5 +580,11 @@ warnings** (~29 MB self-contained EXE).
## Sample
-`Applications/MinimalBoilerServer/` — a fully self-contained, NativeAOT
-single-file Boiler server. Read it top-to-bottom in <200 lines.
+- `Applications/MinimalBoilerServer/` — a fully self-contained,
+ NativeAOT single-file Boiler server. Read it top-to-bottom in
+ <200 lines.
+- `Applications/MinimalCalcServer/` — a calculator server that
+ exercises the typed
+ [methods-with-arguments OnCall overloads](#methods-with-arguments--typed-oncall-overloads)
+ end-to-end (sync `int+int → int`, async `double+double → double`,
+ sync `string+string → string`).
diff --git a/Libraries/Opc.Ua.Server/Fluent/EventNotifierBuilderExtensions.cs b/Libraries/Opc.Ua.Server/Fluent/EventNotifierBuilderExtensions.cs
new file mode 100644
index 0000000000..d0962fc01a
--- /dev/null
+++ b/Libraries/Opc.Ua.Server/Fluent/EventNotifierBuilderExtensions.cs
@@ -0,0 +1,177 @@
+/* ========================================================================
+ * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System;
+using System.Collections.Generic;
+using System.Threading;
+
+namespace Opc.Ua.Server.Fluent
+{
+ ///
+ /// Extension methods that register an external event source against a
+ /// notifier node resolved by the fluent
+ /// surface. The source's items are delivered through
+ /// on the notifier, so monitored
+ /// items on the notifier (or on an ancestor that the notifier is
+ /// reachable from via inverse HasNotifier references) receive
+ /// the events using the standard OPC UA event-dispatch path.
+ ///
+ ///
+ ///
+ /// The extensions require the manager being wired to derive from
+ /// (the
+ /// is attached during the manager's
+ /// startup). Calling Publish on a builder backed by a manager
+ /// that does not opt in throws
+ /// with
+ /// .
+ ///
+ ///
+ /// By default each registered source is lazy: the iterator only runs
+ /// while at least one monitored item is interested in the notifier.
+ /// See for tuning.
+ ///
+ ///
+ public static class EventNotifierBuilderExtensions
+ {
+ ///
+ /// Registers as the event source for
+ /// the resolved notifier. is invoked
+ /// each time the source activates (lazy default activates on the
+ /// first monitored-item subscription; eager activation under
+ /// activates once at
+ /// builder seal time). The factory receives the notifier node,
+ /// the manager's , and a
+ /// that the iterator is required
+ /// to honor.
+ ///
+ ///
+ /// Notifier node type. Constrained to
+ /// because only object nodes carry
+ /// the EventNotifier attribute.
+ ///
+ ///
+ /// Event payload type. Must derive from
+ /// ; the registry covariantly streams
+ /// the items as BaseEventState through
+ /// ReportEvent.
+ ///
+ public static INodeBuilder Publish(
+ this INodeBuilder nodeBuilder,
+ Func> factory,
+ EventPublishOptions options = null)
+ where TNotifier : BaseObjectState
+ where TEvent : BaseEventState
+ {
+ if (nodeBuilder == null)
+ {
+ throw new ArgumentNullException(nameof(nodeBuilder));
+ }
+ if (factory == null)
+ {
+ throw new ArgumentNullException(nameof(factory));
+ }
+
+ EventSourceRegistry registry = GetRegistryOrThrow(nodeBuilder);
+ TNotifier notifier = nodeBuilder.Node;
+
+ // IAsyncEnumerable is covariant on TEvent so the
+ // cast at GetAsyncEnumerator time is allocation-free.
+ registry.Register(
+ notifier,
+ (n, ctx, ct) => factory((TNotifier)n, ctx, ct),
+ options);
+
+ return nodeBuilder;
+ }
+
+ ///
+ /// Registers the supplied as the event
+ /// source for the resolved notifier. Each activation of the
+ /// source calls GetAsyncEnumerator on the same
+ /// instance — callers whose source is
+ /// not re-iterable (e.g. a one-shot iterator) should use the
+ /// factory overload instead.
+ ///
+ ///
+ /// Notifier node type. Constrained to
+ /// .
+ ///
+ ///
+ /// Event payload type. Must derive from
+ /// .
+ ///
+ public static INodeBuilder Publish(
+ this INodeBuilder nodeBuilder,
+ IAsyncEnumerable source,
+ EventPublishOptions options = null)
+ where TNotifier : BaseObjectState
+ where TEvent : BaseEventState
+ {
+ if (nodeBuilder == null)
+ {
+ throw new ArgumentNullException(nameof(nodeBuilder));
+ }
+ if (source == null)
+ {
+ throw new ArgumentNullException(nameof(source));
+ }
+
+ EventSourceRegistry registry = GetRegistryOrThrow(nodeBuilder);
+ TNotifier notifier = nodeBuilder.Node;
+
+ registry.Register(
+ notifier,
+ (_, _, _) => source,
+ options);
+
+ return nodeBuilder;
+ }
+
+ private static EventSourceRegistry GetRegistryOrThrow(
+ INodeBuilder nodeBuilder)
+ where TNotifier : NodeState
+ {
+ if (nodeBuilder.Builder is not NodeManagerBuilder concrete ||
+ concrete.EventSources == null)
+ {
+ string managerTypeName = nodeBuilder.Builder?.NodeManager?.GetType().FullName
+ ?? "(unknown)";
+
+ throw ServiceResultException.Create(
+ StatusCodes.BadConfigurationError,
+ "Publish requires the node manager to derive from FluentNodeManagerBase. " +
+ "Manager type '{0}' does not opt in.",
+ managerTypeName);
+ }
+
+ return concrete.EventSources;
+ }
+ }
+}
diff --git a/Libraries/Opc.Ua.Server/Fluent/EventPublishOptions.cs b/Libraries/Opc.Ua.Server/Fluent/EventPublishOptions.cs
new file mode 100644
index 0000000000..d9f8a9af3f
--- /dev/null
+++ b/Libraries/Opc.Ua.Server/Fluent/EventPublishOptions.cs
@@ -0,0 +1,110 @@
+/* ========================================================================
+ * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System;
+
+namespace Opc.Ua.Server.Fluent
+{
+ ///
+ /// Optional knobs for
+ /// EventNotifierBuilderExtensions.Publish.
+ ///
+ ///
+ ///
+ /// All properties are init-only so the same options instance can safely be
+ /// reused across multiple Publish registrations.
+ ///
+ ///
+ /// Default behavior (no options supplied): lazy activation, default-field
+ /// population enabled, error policy logs and continues, not registered as
+ /// a root notifier.
+ ///
+ ///
+ public sealed record EventPublishOptions
+ {
+ ///
+ /// When true, the source's iterator is pumped immediately at
+ /// builder seal time and stays active until the manager disposes —
+ /// regardless of whether any client is monitoring the node. Useful
+ /// for diagnostic event streams whose backpressure or history-storage
+ /// guarantees must be preserved across reconnect storms. Default
+ /// false (lazy activation: events are pulled only while
+ /// is true).
+ ///
+ public bool AlwaysOn { get; init; }
+
+ ///
+ /// When true, the registry does NOT auto-populate
+ /// EventId, EventType, SourceNode,
+ /// SourceName, Time, ReceiveTime,
+ /// Severity, and Message on each yielded event. Use
+ /// this when your iterator emits fully-populated events
+ /// (e.g. replayed from a history store). Default false
+ /// (matches BaseEventState.Initialize).
+ ///
+ public bool SkipDefaultPopulation { get; init; }
+
+ ///
+ /// When true, the notifier node is added to the manager's
+ /// root-notifier collection so its events propagate to clients that
+ /// subscribe to the Server object. Default false:
+ /// only direct subscribers to the notifier (or to an ancestor that
+ /// the notifier is reachable from via inverse HasNotifier
+ /// references) receive events.
+ ///
+ ///
+ /// Enabling this option overwrites the notifier's
+ /// with the manager's
+ /// root-notifier handler, which is incompatible with attaching an
+ /// OnEvent interceptor or with direct per-node event monitored
+ /// items on the same notifier; the runtime emits a debug-level log
+ /// line documenting the trade-off.
+ ///
+ public bool RegisterAsRootNotifier { get; init; }
+
+ ///
+ /// Maximum time the registry waits for the iterator to honor its
+ /// when the source
+ /// deactivates or the manager disposes. After the timeout, the source
+ /// is flagged as leaked: any further events the iterator yields are
+ /// silently dropped. Default 5 seconds.
+ ///
+ public TimeSpan CancellationTimeout { get; init; } = TimeSpan.FromSeconds(5);
+
+ ///
+ /// Optional sink that observes exceptions thrown by the factory
+ /// invocation, the iterator MoveNextAsync call, or
+ /// . The registry already logs at
+ /// Error level using the manager's logger; this hook is for
+ /// callers that want to bubble the exception into their own
+ /// telemetry. Default null.
+ ///
+ public Action OnError { get; init; }
+ }
+}
diff --git a/Libraries/Opc.Ua.Server/Fluent/EventSourceRegistry.cs b/Libraries/Opc.Ua.Server/Fluent/EventSourceRegistry.cs
new file mode 100644
index 0000000000..169cf7f9f2
--- /dev/null
+++ b/Libraries/Opc.Ua.Server/Fluent/EventSourceRegistry.cs
@@ -0,0 +1,646 @@
+/* ========================================================================
+ * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+
+namespace Opc.Ua.Server.Fluent
+{
+ ///
+ /// Coordinates the lifecycle of Publish-registered event sources
+ /// for a single node manager. Activation is driven off
+ /// : each call to the manager's
+ /// SubscribeToEvents override triggers a reconcile pass that
+ /// starts iterators for newly-monitored sources and cancels iterators
+ /// for sources whose subscriber count dropped to zero. Disposing the
+ /// registry cancels every running iterator and waits for them to drain
+ /// (bounded by ).
+ ///
+ ///
+ ///
+ /// The registry runs a single background reconcile loop that consumes
+ /// signals via a . Signals coalesce — multiple
+ /// rapid sub/unsub calls produce at most one extra reconcile pass.
+ /// Each registered source runs in its own background
+ /// so backpressure on one source does not block another.
+ ///
+ ///
+ /// Threading: takes a short private lock to
+ /// mutate the source dictionary; everything else runs lock-free off
+ /// the reconcile worker.
+ ///
+ ///
+ internal sealed class EventSourceRegistry : IDisposable
+ {
+ public EventSourceRegistry(
+ FluentNodeManagerBase owner,
+ ILogger logger)
+ {
+ m_owner = owner ?? throw new ArgumentNullException(nameof(owner));
+ m_logger = logger;
+ m_reconcileSignal = new SemaphoreSlim(0, 1);
+ m_managerCts = new CancellationTokenSource();
+ m_reconcileTask = Task.Run(() => RunReconcileLoopAsync(m_managerCts.Token));
+ }
+
+ ///
+ /// Registers as the event source for
+ /// . Auto-promotes the notifier's
+ /// flag and, when
+ /// requested in , also registers the
+ /// notifier as a root notifier with the owning manager. Throws if a
+ /// source is already registered for the same notifier.
+ ///
+ ///
+ /// Designed to be called from the manager's Configure
+ /// delegate, which runs single-threaded before clients connect.
+ /// In that context the synchronous wait on
+ ///
+ /// (when
+ /// is set) cannot deadlock because no other thread is contending
+ /// on the manager's monitored-item semaphore.
+ ///
+ public void Register(
+ BaseObjectState notifier,
+ Func> factory,
+ EventPublishOptions options)
+ {
+ if (notifier == null)
+ {
+ throw new ArgumentNullException(nameof(notifier));
+ }
+ if (factory == null)
+ {
+ throw new ArgumentNullException(nameof(factory));
+ }
+
+ options ??= new EventPublishOptions();
+
+ ValidateOptions(options);
+
+ lock (m_sourcesLock)
+ {
+ ThrowIfDisposed();
+ if (m_sources.ContainsKey(notifier.NodeId))
+ {
+ throw ServiceResultException.Create(
+ StatusCodes.BadConfigurationError,
+ "Node '{0}' (id '{1}') already has a Publish event source registered.",
+ notifier.BrowseName,
+ notifier.NodeId);
+ }
+
+ // Auto-promote EventNotifier so clients can subscribe to events on
+ // the node — Boiler-style models do not set this flag by default.
+ if ((notifier.EventNotifier & EventNotifiers.SubscribeToEvents) == 0)
+ {
+ notifier.EventNotifier |= EventNotifiers.SubscribeToEvents;
+ m_logger?.LogDebug(
+ "Publish: promoted EventNotifier of '{Browse}' (id '{NodeId}') to include SubscribeToEvents.",
+ notifier.BrowseName,
+ notifier.NodeId);
+ }
+
+ m_sources[notifier.NodeId] = new SourceEntry(notifier, factory, options);
+ }
+
+ // Root-notifier registration runs eagerly OUTSIDE m_sourcesLock so
+ // existing Server-level event monitored items get attached
+ // immediately. Lazy activation gated on AreEventsMonitored cannot
+ // bootstrap a root notifier (the gate is on the wrong node).
+ if (options.RegisterAsRootNotifier)
+ {
+ try
+ {
+ m_owner.AddRootNotifierFromFluentAsync(notifier, CancellationToken.None)
+ .GetAwaiter()
+ .GetResult();
+ m_logger?.LogDebug(
+ "Publish: registered '{Browse}' (id '{NodeId}') as a root notifier (RegisterAsRootNotifier=true).",
+ notifier.BrowseName,
+ notifier.NodeId);
+ }
+ catch (Exception ex)
+ {
+ lock (m_sourcesLock)
+ {
+ m_sources.Remove(notifier.NodeId);
+ }
+ throw ServiceResultException.Create(
+ StatusCodes.BadConfigurationError,
+ ex,
+ "Publish: failed to add '{0}' (id '{1}') as a root notifier.",
+ notifier.BrowseName,
+ notifier.NodeId);
+ }
+ }
+
+ SignalReconcile();
+ }
+
+ private static void ValidateOptions(EventPublishOptions options)
+ {
+ TimeSpan timeout = options.CancellationTimeout;
+ if (timeout != Timeout.InfiniteTimeSpan && timeout < TimeSpan.Zero)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(options),
+ timeout,
+ "EventPublishOptions.CancellationTimeout must be non-negative or Timeout.InfiniteTimeSpan.");
+ }
+ }
+
+ ///
+ /// Signals the registry to walk every source and reconcile its
+ /// activation state against .
+ /// Called by after the base
+ /// SubscribeToEvents implementation has updated the ref-count
+ /// recursively.
+ ///
+ public void SignalReconcile()
+ {
+ if (Volatile.Read(ref m_disposed) != 0)
+ {
+ return;
+ }
+ // Coalesce: only one reconcile pass is pending at a time.
+ try
+ {
+ m_reconcileSignal.Release();
+ }
+ catch (SemaphoreFullException)
+ {
+ // Another signal is already pending — that's the whole point.
+ }
+ catch (ObjectDisposedException)
+ {
+ // Lost the race with Dispose — that's fine, the registry is
+ // shutting down and no further reconciliation is needed.
+ }
+ }
+
+ ///
+ /// Cancels every running iterator, waits for them to drain (bounded
+ /// by each source's ),
+ /// and stops the reconcile loop. Idempotent.
+ ///
+ public void Dispose()
+ {
+ if (Interlocked.Exchange(ref m_disposed, 1) != 0)
+ {
+ return;
+ }
+
+ // Stop the reconcile loop first so it cannot race with us.
+ try
+ {
+ m_managerCts.Cancel();
+ }
+ catch (ObjectDisposedException)
+ {
+ }
+
+ // Reconcile loop awaits the signal — releasing here unblocks
+ // its WaitAsync so it can observe the cancellation token.
+ try
+ {
+ m_reconcileSignal.Release();
+ }
+ catch (SemaphoreFullException)
+ {
+ }
+ catch (ObjectDisposedException)
+ {
+ }
+
+ // Wait for the reconcile task to actually exit. We must wait long
+ // enough to cover the worst case where the loop is mid-pass when
+ // the cancel fires: the in-flight ReconcileAll may DeactivateSource
+ // each registered source, and each DeactivateSource blocks on its
+ // worker task for up to entry.Options.CancellationTimeout. Use the
+ // largest per-source timeout, plus a safety margin, instead of a
+ // hardcoded 5s that could be exceeded by a single slow source.
+ TimeSpan waitFor = ComputeReconcileWaitTimeout();
+ try
+ {
+ m_reconcileTask.Wait(waitFor);
+ }
+ catch (AggregateException)
+ {
+ }
+
+ // Deactivate every source so their iterators get cancelled.
+ List snapshot;
+ lock (m_sourcesLock)
+ {
+ snapshot = new List(m_sources.Values);
+ m_sources.Clear();
+ }
+
+ foreach (SourceEntry entry in snapshot)
+ {
+ DeactivateSource(entry, force: true);
+ }
+
+ m_reconcileSignal.Dispose();
+ m_managerCts.Dispose();
+ }
+
+ private TimeSpan ComputeReconcileWaitTimeout()
+ {
+ TimeSpan maxPerSource = TimeSpan.Zero;
+ lock (m_sourcesLock)
+ {
+ foreach (SourceEntry entry in m_sources.Values)
+ {
+ TimeSpan t = entry.Options.CancellationTimeout;
+ if (t == Timeout.InfiniteTimeSpan)
+ {
+ return Timeout.InfiniteTimeSpan;
+ }
+ if (t > maxPerSource)
+ {
+ maxPerSource = t;
+ }
+ }
+ }
+ // The reconcile loop only needs the larger of its in-flight pass and
+ // a small bookkeeping margin; per-source deactivation runs again on
+ // the disposer thread below.
+ return maxPerSource + TimeSpan.FromSeconds(5);
+ }
+
+ private async Task RunReconcileLoopAsync(CancellationToken ct)
+ {
+ while (!ct.IsCancellationRequested)
+ {
+ try
+ {
+ await m_reconcileSignal.WaitAsync(ct).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ return;
+ }
+ catch (ObjectDisposedException)
+ {
+ return;
+ }
+
+ if (ct.IsCancellationRequested)
+ {
+ return;
+ }
+
+ ReconcileAll();
+ }
+ }
+
+ private void ReconcileAll()
+ {
+ List snapshot;
+ lock (m_sourcesLock)
+ {
+ snapshot = new List(m_sources.Values);
+ }
+
+ foreach (SourceEntry entry in snapshot)
+ {
+ try
+ {
+ bool wantActive = entry.Options.AlwaysOn || entry.Notifier.AreEventsMonitored;
+ if (wantActive && entry.WorkerCts == null)
+ {
+ ActivateSource(entry);
+ }
+ else if (!wantActive && entry.WorkerCts != null)
+ {
+ DeactivateSource(entry, force: false);
+ }
+ }
+ catch (Exception ex)
+ {
+ m_logger?.LogError(
+ ex,
+ "Publish: reconcile pass failed for '{Browse}' (id '{NodeId}').",
+ entry.Notifier.BrowseName,
+ entry.Notifier.NodeId);
+ }
+ }
+ }
+
+ private void ActivateSource(SourceEntry entry)
+ {
+ var cts = CancellationTokenSource.CreateLinkedTokenSource(m_managerCts.Token);
+ entry.WorkerCts = cts;
+ Volatile.Write(ref entry.LeakedFaulted, 0);
+ entry.WorkerTask = Task.Run(() => RunSourceAsync(entry, cts.Token));
+ m_logger?.LogDebug(
+ "Publish: activated source for '{Browse}' (id '{NodeId}').",
+ entry.Notifier.BrowseName,
+ entry.Notifier.NodeId);
+ }
+
+ private void DeactivateSource(SourceEntry entry, bool force)
+ {
+ CancellationTokenSource cts = entry.WorkerCts;
+ Task worker = entry.WorkerTask;
+ entry.WorkerCts = null;
+ entry.WorkerTask = null;
+
+ if (cts == null)
+ {
+ return;
+ }
+
+ try
+ {
+ cts.Cancel();
+ }
+ catch (ObjectDisposedException)
+ {
+ }
+
+ try
+ {
+ bool completed = worker?.Wait(entry.Options.CancellationTimeout) ?? true;
+ if (!completed)
+ {
+ Volatile.Write(ref entry.LeakedFaulted, 1);
+ m_logger?.LogWarning(
+ "Publish: source for '{Browse}' (id '{NodeId}') did not honor cancellation within {Timeout}; further yielded events will be discarded.",
+ entry.Notifier.BrowseName,
+ entry.Notifier.NodeId,
+ entry.Options.CancellationTimeout);
+ }
+ }
+ catch (AggregateException ex)
+ {
+ ex.Handle(e => e is OperationCanceledException);
+ }
+ finally
+ {
+ cts.Dispose();
+ }
+
+ if (force)
+ {
+ m_logger?.LogDebug(
+ "Publish: tore down source for '{Browse}' (id '{NodeId}') on dispose.",
+ entry.Notifier.BrowseName,
+ entry.Notifier.NodeId);
+ }
+ else
+ {
+ m_logger?.LogDebug(
+ "Publish: deactivated source for '{Browse}' (id '{NodeId}').",
+ entry.Notifier.BrowseName,
+ entry.Notifier.NodeId);
+ }
+ }
+
+ private async Task RunSourceAsync(SourceEntry entry, CancellationToken ct)
+ {
+ ISystemContext systemContext = m_owner.SystemContext;
+
+ System.Collections.Generic.IAsyncEnumerable stream;
+ try
+ {
+ stream = entry.Factory(entry.Notifier, systemContext, ct);
+ if (stream == null)
+ {
+ m_logger?.LogError(
+ "Publish: factory for '{Browse}' (id '{NodeId}') returned a null stream.",
+ entry.Notifier.BrowseName,
+ entry.Notifier.NodeId);
+ return;
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ return;
+ }
+ catch (Exception ex)
+ {
+ m_logger?.LogError(
+ ex,
+ "Publish: factory invocation for '{Browse}' (id '{NodeId}') threw.",
+ entry.Notifier.BrowseName,
+ entry.Notifier.NodeId);
+ try
+ {
+ entry.Options.OnError?.Invoke(ex);
+ }
+ catch
+ {
+ // Swallow secondary errors from the user hook.
+ }
+ return;
+ }
+
+ try
+ {
+ await foreach (BaseEventState e in stream.WithCancellation(ct).ConfigureAwait(false))
+ {
+ if (ct.IsCancellationRequested ||
+ Volatile.Read(ref entry.LeakedFaulted) != 0)
+ {
+ return;
+ }
+ DispatchEvent(entry, systemContext, e);
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ // Normal shutdown.
+ }
+ catch (Exception ex)
+ {
+ m_logger?.LogError(
+ ex,
+ "Publish: iterator for '{Browse}' (id '{NodeId}') threw — stopping that source only.",
+ entry.Notifier.BrowseName,
+ entry.Notifier.NodeId);
+ try
+ {
+ entry.Options.OnError?.Invoke(ex);
+ }
+ catch
+ {
+ }
+ }
+ }
+
+ private void DispatchEvent(SourceEntry entry, ISystemContext context, BaseEventState e)
+ {
+ if (e == null)
+ {
+ return;
+ }
+
+ try
+ {
+ if (!entry.Options.SkipDefaultPopulation)
+ {
+ PopulateDefaults(entry.Notifier, context, e);
+ }
+
+ entry.Notifier.ReportEvent(context, e);
+ }
+ catch (Exception ex)
+ {
+ m_logger?.LogError(
+ ex,
+ "Publish: ReportEvent for '{Browse}' (id '{NodeId}') threw — dropping this event and continuing iterator.",
+ entry.Notifier.BrowseName,
+ entry.Notifier.NodeId);
+ try
+ {
+ entry.Options.OnError?.Invoke(ex);
+ }
+ catch
+ {
+ }
+ }
+ }
+
+ ///
+ /// Mirrors BaseEventState.Initialize for fields the user
+ /// did not populate themselves: EventId (random uuid),
+ /// EventType / TypeDefinitionId (default for the
+ /// CLR type), SourceNode, SourceName, Time,
+ /// ReceiveTime, Severity (Medium when 0),
+ /// Message.
+ ///
+ private static void PopulateDefaults(BaseObjectState notifier, ISystemContext context, BaseEventState e)
+ {
+ if (e.EventId == null || e.EventId.Value.IsNull)
+ {
+ e.EventId = PropertyState.With(
+ e,
+ Uuid.NewUuid().ToByteString());
+ }
+
+ if (e.EventType == null || e.EventType.Value.IsNull)
+ {
+ NodeId defaultType = e.GetDefaultTypeDefinitionId(context);
+ e.EventType = PropertyState.With(e, defaultType);
+ if (e.TypeDefinitionId.IsNull)
+ {
+ e.TypeDefinitionId = defaultType;
+ }
+ }
+ else if (e.TypeDefinitionId.IsNull)
+ {
+ e.TypeDefinitionId = e.EventType.Value;
+ }
+
+ if ((e.SourceNode == null || e.SourceNode.Value.IsNull) &&
+ !notifier.NodeId.IsNull)
+ {
+ e.SourceNode = PropertyState.With(e, notifier.NodeId);
+ }
+
+ if ((e.SourceName == null || e.SourceName.Value == null) &&
+ !notifier.BrowseName.IsNull)
+ {
+ e.SourceName = PropertyState.With(
+ e,
+ notifier.BrowseName.Name);
+ }
+
+ if (e.Time == null || e.Time.Value.IsNull)
+ {
+ e.Time = PropertyState.With(e, DateTimeUtc.Now);
+ }
+
+ if (e.ReceiveTime == null || e.ReceiveTime.Value.IsNull)
+ {
+ e.ReceiveTime = PropertyState.With(
+ e,
+ DateTimeUtc.Now);
+ }
+
+ if (e.Severity == null || e.Severity.Value == 0)
+ {
+ e.Severity = PropertyState.With(
+ e,
+ (ushort)EventSeverity.Medium);
+ }
+
+ if (e.Message == null)
+ {
+ e.Message = PropertyState.With(
+ e,
+ new LocalizedText(string.Empty));
+ }
+ }
+
+ private void ThrowIfDisposed()
+ {
+ if (Volatile.Read(ref m_disposed) != 0)
+ {
+ throw new ObjectDisposedException(nameof(EventSourceRegistry));
+ }
+ }
+
+ private sealed class SourceEntry
+ {
+ public SourceEntry(
+ BaseObjectState notifier,
+ Func> factory,
+ EventPublishOptions options)
+ {
+ Notifier = notifier;
+ Factory = factory;
+ Options = options;
+ }
+
+ public BaseObjectState Notifier { get; }
+ public Func> Factory { get; }
+ public EventPublishOptions Options { get; }
+ public CancellationTokenSource WorkerCts;
+ public Task WorkerTask;
+ public int LeakedFaulted;
+ }
+
+ private readonly FluentNodeManagerBase m_owner;
+ private readonly ILogger m_logger;
+ private readonly SemaphoreSlim m_reconcileSignal;
+ private readonly CancellationTokenSource m_managerCts;
+ private readonly Task m_reconcileTask;
+ private readonly object m_sourcesLock = new object();
+ private readonly Dictionary m_sources = new Dictionary();
+ private int m_disposed;
+ }
+}
diff --git a/Libraries/Opc.Ua.Server/Fluent/FluentNodeManagerBase.cs b/Libraries/Opc.Ua.Server/Fluent/FluentNodeManagerBase.cs
new file mode 100644
index 0000000000..32a6af41d8
--- /dev/null
+++ b/Libraries/Opc.Ua.Server/Fluent/FluentNodeManagerBase.cs
@@ -0,0 +1,221 @@
+/* ========================================================================
+ * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+
+namespace Opc.Ua.Server.Fluent
+{
+ ///
+ /// Opt-in base class for node managers that want to use the fluent
+ /// Publish surface (external event sources delivered through
+ /// ). The source-generator-emitted
+ /// NodeManagerBase derives from this class when any wrapper
+ /// in the design exposes a Publish binding; hand-written
+ /// managers can also derive directly.
+ ///
+ ///
+ ///
+ /// This class owns an whose
+ /// reconcile loop runs as long as the manager is alive. Override
+ /// hooks the registry so it
+ /// activates and deactivates sources in lock-step with
+ /// . Dispose(bool)
+ /// tears the registry down before the base implementation runs so
+ /// no iterator outlives the manager.
+ ///
+ ///
+ /// Subclasses should never call into
+ /// outside the fluent builder pipeline; the surface is exposed for
+ /// generated code only.
+ ///
+ ///
+ public abstract class FluentNodeManagerBase : AsyncCustomNodeManager
+ {
+ ///
+ /// Initializes the node manager.
+ ///
+ protected FluentNodeManagerBase(
+ IServerInternal server,
+ params string[] namespaceUris)
+ : base(server, namespaceUris)
+ {
+ m_eventSources = new EventSourceRegistry(this, m_logger);
+ }
+
+ ///
+ /// Initializes the node manager.
+ ///
+ protected FluentNodeManagerBase(
+ IServerInternal server,
+ ILogger logger,
+ params string[] namespaceUris)
+ : base(server, logger, namespaceUris)
+ {
+ m_eventSources = new EventSourceRegistry(this, m_logger);
+ }
+
+ ///
+ /// Initializes the node manager.
+ ///
+ protected FluentNodeManagerBase(
+ IServerInternal server,
+ ApplicationConfiguration configuration,
+ params string[] namespaceUris)
+ : base(server, configuration, namespaceUris)
+ {
+ m_eventSources = new EventSourceRegistry(this, m_logger);
+ }
+
+ ///
+ /// Initializes the node manager.
+ ///
+ protected FluentNodeManagerBase(
+ IServerInternal server,
+ ApplicationConfiguration configuration,
+ ILogger logger,
+ params string[] namespaceUris)
+ : base(server, configuration, logger, namespaceUris)
+ {
+ m_eventSources = new EventSourceRegistry(this, m_logger);
+ }
+
+ ///
+ /// Initializes the node manager.
+ ///
+ protected FluentNodeManagerBase(
+ IServerInternal server,
+ ApplicationConfiguration configuration,
+ bool useSamplingGroups,
+ params string[] namespaceUris)
+ : base(server, configuration, useSamplingGroups, namespaceUris)
+ {
+ m_eventSources = new EventSourceRegistry(this, m_logger);
+ }
+
+ ///
+ /// Initializes the node manager.
+ ///
+ protected FluentNodeManagerBase(
+ IServerInternal server,
+ ApplicationConfiguration configuration,
+ bool useSamplingGroups,
+ ILogger logger,
+ params string[] namespaceUris)
+ : base(server, configuration, useSamplingGroups, logger, namespaceUris)
+ {
+ m_eventSources = new EventSourceRegistry(this, m_logger);
+ }
+
+ ///
+ /// Registry that the fluent Publish surface stores its
+ /// registered event sources in. Accessed by
+ /// during
+ /// Configure and by generated wrappers; not intended for
+ /// direct subclass use.
+ ///
+ internal EventSourceRegistry EventSources => m_eventSources;
+
+ ///
+ /// Attaches this manager's event-source registry to the supplied
+ /// fluent builder so that Publish extension methods can
+ /// resolve it. The generator-emitted CreateAddressSpaceAsync
+ /// invokes this immediately after constructing the builder; hand-
+ /// written managers that build their own
+ /// should call this once before
+ /// passing the builder into Configure.
+ ///
+ ///
+ /// The fluent builder that the manager's Configure
+ /// partial(s) will receive.
+ ///
+ ///
+ /// Raised when is null.
+ ///
+ public void AttachToBuilder(NodeManagerBuilder builder)
+ {
+ if (builder == null)
+ {
+ throw new System.ArgumentNullException(nameof(builder));
+ }
+ builder.AttachEventSources(m_eventSources);
+ }
+
+ ///
+ /// Signals the registry whenever a notifier's monitored-events
+ /// ref-count flips so the reconcile loop can start or stop the
+ /// matching iterator. Subclasses that further override
+ ///
+ /// must call base before doing their own work.
+ ///
+ protected override ValueTask OnSubscribeToEventsAsync(
+ ServerSystemContext context,
+ MonitoredNode2 monitoredNode,
+ bool unsubscribe,
+ CancellationToken cancellationToken = default)
+ {
+ m_eventSources.SignalReconcile();
+ return base.OnSubscribeToEventsAsync(context, monitoredNode, unsubscribe, cancellationToken);
+ }
+
+ ///
+ /// Cancels every running iterator and waits (bounded by each
+ /// source's
+ /// ) before
+ /// invoking the base disposer. Subclasses that further override
+ /// Dispose must call base.Dispose(disposing).
+ ///
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing)
+ {
+ m_eventSources.Dispose();
+ }
+ base.Dispose(disposing);
+ }
+
+ ///
+ /// Internal trampoline used by
+ /// to register a notifier as a root notifier from a background
+ /// worker thread. Wraps
+ ///
+ /// so the registry does not have to know its protected
+ /// signature.
+ ///
+ internal Task AddRootNotifierFromFluentAsync(
+ NodeState notifier,
+ CancellationToken cancellationToken)
+ {
+ return AddRootNotifierAsync(notifier, cancellationToken).AsTask();
+ }
+
+ private readonly EventSourceRegistry m_eventSources;
+ }
+}
diff --git a/Libraries/Opc.Ua.Server/Fluent/INodeBuilder.cs b/Libraries/Opc.Ua.Server/Fluent/INodeBuilder.cs
index a872e77f45..52fb2df029 100644
--- a/Libraries/Opc.Ua.Server/Fluent/INodeBuilder.cs
+++ b/Libraries/Opc.Ua.Server/Fluent/INodeBuilder.cs
@@ -93,6 +93,31 @@ public interface INodeBuilder
///
INodeBuilder OnWrite(NodeValueSimpleEventHandler handler);
+ ///
+ /// Wires . Use this
+ /// overload when the read source performs I/O — the handler runs
+ /// without holding lock(this), so the call can await
+ /// freely.
+ ///
+ INodeBuilder OnRead(NodeValueEventHandlerAsync handler);
+
+ ///
+ /// Wires .
+ /// The framework still applies index-range / data-encoding /
+ /// copy-policy post-processing to the returned value.
+ ///
+ INodeBuilder OnRead(NodeValueSimpleEventHandlerAsync handler);
+
+ ///
+ /// Wires .
+ ///
+ INodeBuilder OnWrite(NodeValueWriteEventHandlerAsync handler);
+
+ ///
+ /// Wires .
+ ///
+ INodeBuilder OnWrite(NodeValueSimpleWriteEventHandlerAsync handler);
+
///
/// Wires .
///
@@ -146,6 +171,41 @@ public interface INodeBuilder
/// hook to forward events to .
///
INodeBuilder OnEvent(EventNotificationHandler handler);
+
+ ///
+ /// Resolves a child of the current node by browse name. Used by
+ /// source-generated typed traversal wrappers to walk one segment
+ /// at a time without re-resolving from the root.
+ ///
+ /// Browse name of the immediate child.
+ ///
+ /// Thrown when the child cannot be found.
+ ///
+ INodeBuilder Child(QualifiedName browseName);
+
+ ///
+ /// Strongly-typed sibling of .
+ ///
+ ///
+ /// CLR type the resolved child must be
+ /// assignable to.
+ ///
+ INodeBuilder Child(QualifiedName browseName)
+ where TState : NodeState;
+
+ ///
+ /// Resolves a variable child of the current node and returns a
+ /// typed view.
+ ///
+ ///
+ /// CLR type carried by the child variable's Value attribute.
+ ///
+ /// Browse name of the immediate child.
+ ///
+ /// Thrown when the child cannot be found, or when it is not a
+ /// .
+ ///
+ IVariableBuilder Variable(QualifiedName browseName);
}
///
diff --git a/Libraries/Opc.Ua.Server/Fluent/INodeManagerBuilder.cs b/Libraries/Opc.Ua.Server/Fluent/INodeManagerBuilder.cs
index 9b74ecd74b..2fa1afd835 100644
--- a/Libraries/Opc.Ua.Server/Fluent/INodeManagerBuilder.cs
+++ b/Libraries/Opc.Ua.Server/Fluent/INodeManagerBuilder.cs
@@ -198,5 +198,43 @@ INodeBuilder NodeFromTypeId(NodeId typeDefinitionId)
/// See .
INodeBuilder NodeFromTypeId(NodeId typeDefinitionId, QualifiedName browseName)
where TState : NodeState;
+
+ ///
+ /// Resolves a variable node by browse path and returns a typed
+ /// view that exposes
+ /// simple Func / Action shaped
+ /// OnRead/OnWrite overloads.
+ ///
+ ///
+ /// CLR type carried by the variable's Value attribute.
+ ///
+ /// See .
+ ///
+ /// Thrown if the path does not resolve, or resolves to a node
+ /// that is not a .
+ ///
+ IVariableBuilder Variable(string browsePath);
+
+ ///
+ /// Resolves a variable node by absolute
+ /// and returns a typed
+ /// view.
+ ///
+ IVariableBuilder Variable(NodeId nodeId);
+
+ ///
+ /// Resolves the unique variable instance whose
+ /// TypeDefinitionId matches
+ /// and returns a typed
+ /// view. Same disambiguation semantics as
+ /// .
+ ///
+ IVariableBuilder VariableFromTypeId(NodeId typeDefinitionId);
+
+ ///
+ /// Like but
+ /// disambiguates among multiple instances by browse name.
+ ///
+ IVariableBuilder VariableFromTypeId(NodeId typeDefinitionId, QualifiedName browseName);
}
}
diff --git a/Libraries/Opc.Ua.Server/Fluent/IVariableBuilder.cs b/Libraries/Opc.Ua.Server/Fluent/IVariableBuilder.cs
new file mode 100644
index 0000000000..0e44539e6a
--- /dev/null
+++ b/Libraries/Opc.Ua.Server/Fluent/IVariableBuilder.cs
@@ -0,0 +1,157 @@
+/* ========================================================================
+ * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Opc.Ua.Server.Fluent
+{
+ ///
+ /// Strongly-typed view of a variable node whose value attribute is
+ /// known at compile time to carry .
+ /// Surfaces convenience overloads of OnRead/OnWrite
+ /// that accept simple /
+ /// shaped lambdas — the framework marshals
+ /// to and from automatically.
+ ///
+ ///
+ ///
+ /// Obtained via
+ /// ,
+ /// or by
+ /// calling
+ /// on an existing .
+ ///
+ ///
+ /// The simple convenience overloads do not surface index range, data
+ /// encoding, or per-call status code information; use the underlying
+ /// overloads (e.g.
+ /// ) when
+ /// that level of control is needed.
+ ///
+ ///
+ ///
+ /// CLR type that the variable's Value attribute is expected to
+ /// carry. Reads cast the underlying to
+ /// this type; writes wrap the supplied value in a new
+ /// .
+ ///
+ public interface IVariableBuilder : INodeBuilder
+ {
+ ///
+ /// Wires a synchronous typed getter. The framework invokes
+ /// on every read and applies index range
+ /// / data encoding / copy policy post-processing to the returned
+ /// value.
+ ///
+ IVariableBuilder OnRead(Func getter);
+
+ ///
+ /// Wires a synchronous typed getter that receives the calling
+ /// .
+ ///
+ IVariableBuilder OnRead(Func getter);
+
+ ///
+ /// Wires an asynchronous typed getter. The handler runs without
+ /// holding lock(this); the framework still applies index
+ /// range / data encoding / copy policy post-processing.
+ ///
+ IVariableBuilder OnRead(
+ Func> getter);
+
+ ///
+ /// Wires an asynchronous typed getter that receives the calling
+ /// and a .
+ ///
+ IVariableBuilder OnRead(
+ Func> getter);
+
+ ///
+ /// Wires a synchronous typed setter. Index-range writes are not
+ /// supported through this overload (use the
+ /// overload on
+ /// ).
+ ///
+ IVariableBuilder OnWrite(Action setter);
+
+ ///
+ /// Wires a synchronous typed setter that receives the calling
+ /// .
+ ///
+ IVariableBuilder OnWrite(Action setter);
+
+ ///
+ /// Wires an asynchronous typed setter. Runs without holding
+ /// lock(this).
+ ///
+ IVariableBuilder OnWrite(
+ Func setter);
+
+ ///
+ /// Wires an asynchronous typed setter that receives the calling
+ /// .
+ ///
+ IVariableBuilder OnWrite(
+ Func setter);
+ }
+
+ ///
+ /// Convenience extensions that bridge a non-generic
+ /// into the strongly-typed
+ /// view.
+ ///
+ public static class VariableBuilderExtensions
+ {
+ ///
+ /// Returns a view of the
+ /// resolved node. Throws if the node is not a
+ /// .
+ ///
+ public static IVariableBuilder AsVariable(this INodeBuilder builder)
+ {
+ if (builder == null)
+ {
+ throw new ArgumentNullException(nameof(builder));
+ }
+
+ if (builder.Node is not BaseVariableState variable)
+ {
+ throw ServiceResultException.Create(
+ StatusCodes.BadInvalidArgument,
+ "Cannot get a typed variable view of node '{0}': not a BaseVariableState (actual: {1}).",
+ builder.Node.BrowseName,
+ builder.Node.GetType().Name);
+ }
+
+ return new VariableBuilder(builder.Builder, variable);
+ }
+ }
+}
diff --git a/Libraries/Opc.Ua.Server/Fluent/NodeBuilder.cs b/Libraries/Opc.Ua.Server/Fluent/NodeBuilder.cs
index f9b1da725c..42eb652bba 100644
--- a/Libraries/Opc.Ua.Server/Fluent/NodeBuilder.cs
+++ b/Libraries/Opc.Ua.Server/Fluent/NodeBuilder.cs
@@ -104,6 +104,42 @@ public INodeBuilder OnWrite(NodeValueSimpleEventHandler handler)
return this;
}
+ ///
+ public INodeBuilder OnRead(NodeValueEventHandlerAsync handler)
+ {
+ BaseVariableState v = RequireVariable("OnReadAsync");
+ ThrowIfSlotOccupied(v.OnReadValueAsync, "OnReadAsync");
+ v.OnReadValueAsync = handler ?? throw new ArgumentNullException(nameof(handler));
+ return this;
+ }
+
+ ///
+ public INodeBuilder OnRead(NodeValueSimpleEventHandlerAsync handler)
+ {
+ BaseVariableState v = RequireVariable("OnSimpleReadAsync");
+ ThrowIfSlotOccupied(v.OnSimpleReadValueAsync, "OnSimpleReadAsync");
+ v.OnSimpleReadValueAsync = handler ?? throw new ArgumentNullException(nameof(handler));
+ return this;
+ }
+
+ ///
+ public INodeBuilder OnWrite(NodeValueWriteEventHandlerAsync handler)
+ {
+ BaseVariableState v = RequireVariable("OnWriteAsync");
+ ThrowIfSlotOccupied(v.OnWriteValueAsync, "OnWriteAsync");
+ v.OnWriteValueAsync = handler ?? throw new ArgumentNullException(nameof(handler));
+ return this;
+ }
+
+ ///
+ public INodeBuilder OnWrite(NodeValueSimpleWriteEventHandlerAsync handler)
+ {
+ BaseVariableState v = RequireVariable("OnSimpleWriteAsync");
+ ThrowIfSlotOccupied(v.OnSimpleWriteValueAsync, "OnSimpleWriteAsync");
+ v.OnSimpleWriteValueAsync = handler ?? throw new ArgumentNullException(nameof(handler));
+ return this;
+ }
+
///
public INodeBuilder OnCall(GenericMethodCalledEventHandler2 handler)
{
@@ -204,6 +240,57 @@ public INodeBuilder OnEvent(EventNotificationHandler handler)
return this;
}
+ ///
+ public INodeBuilder Child(QualifiedName browseName)
+ {
+ NodeState child = ResolveChild(browseName);
+ return new NodeBuilder(m_parent, child);
+ }
+
+ ///
+ public INodeBuilder Child(QualifiedName browseName)
+ where TState : NodeState
+ {
+ NodeState child = ResolveChild(browseName);
+ if (child is not TState typed)
+ {
+ throw ServiceResultException.Create(
+ StatusCodes.BadTypeMismatch,
+ "Child '{0}' under '{1}' is of type {2}, which is not assignable to {3}.",
+ browseName,
+ Node.BrowseName,
+ child.GetType().Name,
+ typeof(TState).Name);
+ }
+ return new NodeBuilder(m_parent, typed);
+ }
+
+ ///
+ public IVariableBuilder Variable(QualifiedName browseName)
+ {
+ NodeState child = ResolveChild(browseName);
+ return m_parent.ToVariableBuilder(
+ child,
+ CoreUtils.Format("{0}/{1}", Node.BrowseName, browseName));
+ }
+
+ private BaseInstanceState ResolveChild(QualifiedName browseName)
+ {
+ if (browseName.IsNull)
+ {
+ throw ServiceResultException.Create(
+ StatusCodes.BadBrowseNameInvalid,
+ "Browse name is null or empty.");
+ }
+ return Node.FindChild(m_parent.Context, browseName) ??
+ throw ServiceResultException.Create(
+ StatusCodes.BadNodeIdUnknown,
+ "Child '{0}' not found under '{1}' (id '{2}').",
+ browseName,
+ Node.BrowseName,
+ Node.NodeId);
+ }
+
private BaseVariableState RequireVariable(string what)
{
if (Node is not BaseVariableState v)
@@ -257,7 +344,7 @@ private void ThrowIfSlotOccupied(Delegate existing, string what)
/// property.
///
///
- internal sealed class NodeBuilder : NodeBuilder, INodeBuilder
+ internal class NodeBuilder : NodeBuilder, INodeBuilder
where TState : NodeState
{
public NodeBuilder(NodeManagerBuilder parent, TState node)
diff --git a/Libraries/Opc.Ua.Server/Fluent/NodeManagerBuilder.cs b/Libraries/Opc.Ua.Server/Fluent/NodeManagerBuilder.cs
index caeca8e769..afc5ef6b85 100644
--- a/Libraries/Opc.Ua.Server/Fluent/NodeManagerBuilder.cs
+++ b/Libraries/Opc.Ua.Server/Fluent/NodeManagerBuilder.cs
@@ -237,6 +237,106 @@ public INodeBuilder NodeFromTypeId(NodeId typeDefinitionId, Qual
return new NodeBuilder(this, typed);
}
+ ///
+ public IVariableBuilder Variable(string browsePath)
+ {
+ ThrowIfSealed();
+ NodeState node = BrowsePathResolver.Resolve(
+ Context,
+ browsePath,
+ m_defaultNamespaceIndex,
+ m_rootResolver);
+ return ToVariableBuilder(node, browsePath);
+ }
+
+ ///
+ public IVariableBuilder Variable(NodeId nodeId)
+ {
+ ThrowIfSealed();
+ NodeState node = ResolveNodeId(nodeId);
+ return ToVariableBuilder(node, FormatNodeId(nodeId));
+ }
+
+ ///
+ public IVariableBuilder VariableFromTypeId(NodeId typeDefinitionId)
+ {
+ ThrowIfSealed();
+ NodeState node = ResolveByTypeDefinition(typeDefinitionId, (QualifiedName)null);
+ return ToVariableBuilder(node, FormatNodeId(typeDefinitionId));
+ }
+
+ ///
+ public IVariableBuilder VariableFromTypeId(NodeId typeDefinitionId, QualifiedName browseName)
+ {
+ ThrowIfSealed();
+ NodeState node = ResolveByTypeDefinition(typeDefinitionId, browseName);
+ return ToVariableBuilder(
+ node,
+ CoreUtils.Format(
+ "{0} (browse name '{1}')",
+ FormatNodeId(typeDefinitionId),
+ browseName));
+ }
+
+ internal VariableBuilder ToVariableBuilder(NodeState node, string lookupHint)
+ {
+ if (node is not BaseVariableState variable)
+ {
+ throw ServiceResultException.Create(
+ StatusCodes.BadTypeMismatch,
+ "Lookup '{0}' resolved to {1}, which is not a BaseVariableState.",
+ lookupHint,
+ node.GetType().Name);
+ }
+ return new VariableBuilder(this, variable);
+ }
+
+ ///
+ /// Event-source registry owned by the
+ /// ; populated via
+ /// immediately after the
+ /// builder is constructed and before Configure runs.
+ ///
+ ///
+ /// Hand-written managers that derive from
+ /// rather than
+ /// leave this property
+ /// null; the Publish extensions surface a
+ /// targeted error in that case.
+ ///
+ internal EventSourceRegistry EventSources { get; private set; }
+
+ ///
+ /// Wires the supplied registry into this builder so the
+ /// Publish extensions can route source registrations to
+ /// the owning manager. Called once by
+ /// ; subsequent calls throw.
+ ///
+ internal void AttachEventSources(EventSourceRegistry registry)
+ {
+ if (registry == null)
+ {
+ throw new System.ArgumentNullException(nameof(registry));
+ }
+
+ if (EventSources != null)
+ {
+ throw ServiceResultException.Create(
+ StatusCodes.BadInvalidState,
+ "An EventSourceRegistry is already attached to this builder.");
+ }
+
+ EventSources = registry;
+ }
+
+ private static string FormatNodeId(NodeId nodeId)
+ {
+ // NodeId is a readonly struct so the caller may pass `default`;
+ // .IsNull guards both the default-struct case and a constructed
+ // NodeId with no identifier.
+ return nodeId.IsNull ? "(null)" : nodeId.ToString();
+ }
+
///
public bool TryHandleHistoryRead(
ISystemContext context,
diff --git a/Libraries/Opc.Ua.Server/Fluent/VariableBuilder.cs b/Libraries/Opc.Ua.Server/Fluent/VariableBuilder.cs
new file mode 100644
index 0000000000..64daf1b8b7
--- /dev/null
+++ b/Libraries/Opc.Ua.Server/Fluent/VariableBuilder.cs
@@ -0,0 +1,214 @@
+/* ========================================================================
+ * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Opc.Ua.Server.Fluent
+{
+ ///
+ /// Default implementation.
+ /// Wraps each typed convenience overload as the appropriate slot
+ /// delegate on the underlying and
+ /// performs marshalling on every read / write.
+ ///
+ ///
+ internal sealed class VariableBuilder :
+ NodeBuilder,
+ IVariableBuilder
+ {
+ public VariableBuilder(INodeManagerBuilder parent, BaseVariableState variable)
+ : base((NodeManagerBuilder)parent, variable)
+ {
+ }
+
+ ///
+ public IVariableBuilder OnRead(Func getter)
+ {
+ if (getter == null)
+ {
+ throw new ArgumentNullException(nameof(getter));
+ }
+ OnRead((ISystemContext _) => getter());
+ return this;
+ }
+
+ ///
+ public IVariableBuilder OnRead(Func getter)
+ {
+ if (getter == null)
+ {
+ throw new ArgumentNullException(nameof(getter));
+ }
+ ((INodeBuilder)this).OnRead(
+ (ISystemContext context, NodeState _, ref Variant value) =>
+ {
+ value = ToVariant(getter(context));
+ return ServiceResult.Good;
+ });
+ return this;
+ }
+
+ ///
+ public IVariableBuilder OnRead(
+ Func> getter)
+ {
+ if (getter == null)
+ {
+ throw new ArgumentNullException(nameof(getter));
+ }
+ return OnRead(
+ (ISystemContext _, CancellationToken ct) => getter(ct));
+ }
+
+ ///
+ public IVariableBuilder OnRead(
+ Func> getter)
+ {
+ if (getter == null)
+ {
+ throw new ArgumentNullException(nameof(getter));
+ }
+ ((INodeBuilder)this).OnRead(
+ async (ISystemContext context, NodeState _, CancellationToken ct) =>
+ {
+ TValue typed = await getter(context, ct).ConfigureAwait(false);
+ return new AttributeSimpleReadResult(
+ ServiceResult.Good,
+ ToVariant(typed));
+ });
+ return this;
+ }
+
+ ///
+ public IVariableBuilder OnWrite(Action setter)
+ {
+ if (setter == null)
+ {
+ throw new ArgumentNullException(nameof(setter));
+ }
+ OnWrite((ISystemContext _, TValue v) => setter(v));
+ return this;
+ }
+
+ ///
+ public IVariableBuilder OnWrite(Action setter)
+ {
+ if (setter == null)
+ {
+ throw new ArgumentNullException(nameof(setter));
+ }
+ ((INodeBuilder)this).OnWrite(
+ (ISystemContext context, NodeState _, ref Variant value) =>
+ {
+ setter(context, FromVariant(value));
+ return ServiceResult.Good;
+ });
+ return this;
+ }
+
+ ///
+ public IVariableBuilder OnWrite(
+ Func setter)
+ {
+ if (setter == null)
+ {
+ throw new ArgumentNullException(nameof(setter));
+ }
+ return OnWrite((ISystemContext _, TValue v, CancellationToken ct) => setter(v, ct));
+ }
+
+ ///
+ public IVariableBuilder OnWrite(
+ Func setter)
+ {
+ if (setter == null)
+ {
+ throw new ArgumentNullException(nameof(setter));
+ }
+ ((INodeBuilder)this).OnWrite(
+ async (ISystemContext context, NodeState _, Variant value, CancellationToken ct) =>
+ {
+ TValue typed = FromVariant(value);
+ await setter(context, typed, ct).ConfigureAwait(false);
+ return new AttributeWriteResult(ServiceResult.Good);
+ });
+ return this;
+ }
+
+ ///
+ /// Casts the boxed to
+ /// . Returns the default value when
+ /// the variant is null so a typed lambda never has to defend
+ /// against an empty variable.
+ ///
+ private static TValue FromVariant(Variant value)
+ {
+ object boxed = value.AsBoxedObject(Variant.BoxingBehavior.Legacy);
+ if (boxed is null)
+ {
+ return default;
+ }
+ if (boxed is TValue typed)
+ {
+ return typed;
+ }
+ // Fall through to checked cast — gives a clear InvalidCastException
+ // (vs. a confusing pattern-match miss) when the model and the user
+ // disagree about the variable type.
+ return (TValue)boxed;
+ }
+
+ // The reflection-based Variant(object) constructor is the only
+ // generic entry point for an open-ended TValue. AOT users should
+ // prefer the per-type generated walker (FluentBuilderGenerator) which
+ // routes through the typed Variant.From overloads instead. The
+ // suppression is scoped to this single call site so trim/AOT
+ // analysis still tracks every other path through this assembly.
+ [System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage(
+ "Trimming",
+ "IL2026:RequiresUnreferencedCode",
+ Justification = "Generic typed-variable bridge requires the reflection-based Variant(object) constructor.")]
+ [System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage(
+ "AOT",
+ "IL3050:RequiresDynamicCode",
+ Justification = "Generic typed-variable bridge requires the reflection-based Variant(object) constructor.")]
+ private static Variant ToVariant(TValue value)
+ {
+ if (value is null)
+ {
+ return Variant.Null;
+ }
+#pragma warning disable CS0618 // Variant(object) is obsolete on net472
+ return new Variant(value);
+#pragma warning restore CS0618
+ }
+ }
+}
diff --git a/Libraries/Opc.Ua.Server/NodeManager/AsyncCustomNodeManager.cs b/Libraries/Opc.Ua.Server/NodeManager/AsyncCustomNodeManager.cs
index 61ff9aafaa..95a4129465 100644
--- a/Libraries/Opc.Ua.Server/NodeManager/AsyncCustomNodeManager.cs
+++ b/Libraries/Opc.Ua.Server/NodeManager/AsyncCustomNodeManager.cs
@@ -1962,15 +1962,18 @@ protected virtual async ValueTask ReadAsync(
DataValue value = values[handle.Index];
// update the attribute value.
- lock (source)
- {
- errors[handle.Index] = source.ReadAttribute(
- context,
- nodeToRead.AttributeId,
- nodeToRead.ParsedIndexRange,
- nodeToRead.DataEncoding,
- value);
- }
+ // Async path — the default NodeState.ReadAttributeAsync re-takes
+ // `lock(this)` around the synchronous fallback when no async
+ // hook is set, so behaviour is preserved bit-for-bit. When a
+ // BaseVariableState async hook is set the lock is dropped on
+ // the await, allowing true async I/O.
+ errors[handle.Index] = await source.ReadAttributeAsync(
+ context,
+ nodeToRead.AttributeId,
+ nodeToRead.ParsedIndexRange,
+ nodeToRead.DataEncoding,
+ value,
+ cancellationToken).ConfigureAwait(false);
}
}
@@ -2372,15 +2375,13 @@ protected virtual async ValueTask WriteAsync(
WriteValue nodeToWrite = nodesToWrite[handle.Index];
- lock (source)
- {
- // write the attribute value.
- errors[handle.Index] = source.WriteAttribute(
- context,
- nodeToWrite.AttributeId,
- nodeToWrite.ParsedIndexRange,
- nodeToWrite.Value);
- }
+ // Async path — see ReadAsync for the locking rationale.
+ errors[handle.Index] = await source.WriteAttributeAsync(
+ context,
+ nodeToWrite.AttributeId,
+ nodeToWrite.ParsedIndexRange,
+ nodeToWrite.Value,
+ cancellationToken).ConfigureAwait(false);
// updates to source finished - report changes to monitored items.
source.ClearChangeMasks(context, false);
diff --git a/Stack/Opc.Ua.Types/State/BaseVariableState.cs b/Stack/Opc.Ua.Types/State/BaseVariableState.cs
index 6645da2887..5cb43813fa 100644
--- a/Stack/Opc.Ua.Types/State/BaseVariableState.cs
+++ b/Stack/Opc.Ua.Types/State/BaseVariableState.cs
@@ -33,6 +33,8 @@
using System.Globalization;
using System.Runtime.Serialization;
using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Opc.Ua.Types;
@@ -561,6 +563,45 @@ public uint AccessLevelEx
///
public NodeValueEventHandler OnWriteValue;
+ ///
+ /// Asynchronous sibling of . When set, the
+ /// async path through
+ ///
+ /// invokes this delegate without holding the lock(this)
+ /// taken by the synchronous fallback — the handler owns its own
+ /// thread-safety. Has the same "I am the read source"
+ /// semantics as : the framework does
+ /// not apply post-processing such as
+ /// on the returned
+ /// value.
+ ///
+ public NodeValueEventHandlerAsync OnReadValueAsync;
+
+ ///
+ /// Asynchronous sibling of . The
+ /// returned value is post-processed by the framework
+ /// ( and copy policy)
+ /// just like the synchronous path.
+ ///
+ public NodeValueSimpleEventHandlerAsync OnSimpleReadValueAsync;
+
+ ///
+ /// Asynchronous sibling of . When set,
+ /// the async path through
+ ///
+ /// invokes this delegate without holding lock(this); on
+ /// success the framework updates the cached value, status code
+ /// and timestamp.
+ ///
+ public NodeValueWriteEventHandlerAsync OnWriteValueAsync;
+
+ ///
+ /// Asynchronous sibling of . Index
+ /// range writes are not supported through this hook (just like
+ /// the synchronous one).
+ ///
+ public NodeValueSimpleWriteEventHandlerAsync OnSimpleWriteValueAsync;
+
///
/// Raised when the DataType attribute is read.
///
@@ -1737,6 +1778,315 @@ protected override ServiceResult WriteValueAttribute(
return ServiceResult.Good;
}
+ ///
+ /// Asynchronous override of
+ /// .
+ /// Dispatches to or
+ /// when set; otherwise falls
+ /// through to the synchronous read flow under lock(this)
+ /// (preserving today's locking semantics for code that has not
+ /// opted into async hooks).
+ ///
+ public override async ValueTask ReadAttributeAsync(
+ ISystemContext context,
+ uint attributeId,
+ NumericRange indexRange,
+ QualifiedName dataEncoding,
+ DataValue value,
+ CancellationToken cancellationToken = default)
+ {
+ NodeValueEventHandlerAsync onReadValueAsync = OnReadValueAsync;
+ NodeValueSimpleEventHandlerAsync onSimpleReadValueAsync = OnSimpleReadValueAsync;
+
+ if (attributeId != Attributes.Value ||
+ (onReadValueAsync == null && onSimpleReadValueAsync == null))
+ {
+ return await base.ReadAttributeAsync(
+ context, attributeId, indexRange, dataEncoding, value, cancellationToken)
+ .ConfigureAwait(false);
+ }
+
+ if (value == null)
+ {
+ return ServiceResult.Create(
+ StatusCodes.BadStructureMissing,
+ "DataValue missing");
+ }
+
+ ServiceResult result;
+ Variant valueToRead;
+ DateTimeUtc sourceTimestamp;
+
+ try
+ {
+ // snapshot access levels / timestamp / status code under the lock.
+ uint accessLevel;
+ byte userAccessLevel;
+ StatusCode cachedStatusCode;
+ DateTimeUtc cachedTimestamp;
+ // TODO: introduce a dedicated private lock object on NodeState
+ // — today's sync flow synchronises through `lock(source)` taken
+ // by external callers (e.g. CustomNodeManager2.Read), so the
+ // async path must lock on the same instance to preserve
+ // mutual exclusion. Switching to a private lock object
+ // requires updating every external `lock(source)` site.
+#pragma warning disable CA2002 // Do not lock on objects with weak identity
+ lock (this)
+#pragma warning restore CA2002
+ {
+ accessLevel = m_accessLevel;
+ userAccessLevel = m_userAccessLevel;
+ cachedStatusCode = m_statusCode;
+ cachedTimestamp = m_timestamp;
+ }
+
+ if ((accessLevel & AccessLevels.CurrentRead) == 0)
+ {
+ result = StatusCodes.BadNotReadable;
+ valueToRead = Variant.Null;
+ sourceTimestamp = DateTimeUtc.MinValue;
+ }
+ else
+ {
+ OnReadUserAccessLevel?.Invoke(context, this, ref userAccessLevel);
+
+ if ((userAccessLevel & AccessLevels.CurrentRead) == 0)
+ {
+ result = StatusCodes.BadUserAccessDenied;
+ valueToRead = Variant.Null;
+ sourceTimestamp = DateTimeUtc.MinValue;
+ }
+ else if (onReadValueAsync != null)
+ {
+ // full async read — user owns timestamp / status code.
+ AttributeReadResult readResult = await onReadValueAsync(
+ context, this, indexRange, dataEncoding, cancellationToken)
+ .ConfigureAwait(false);
+
+ result = readResult.Result;
+ valueToRead = readResult.Value;
+ sourceTimestamp = readResult.SourceTimestamp == DateTimeUtc.MinValue
+ ? DateTimeUtc.Now
+ : readResult.SourceTimestamp;
+
+ // mirror sync OnReadValue status-code fixup.
+ if (ServiceResult.IsGood(result) &&
+ readResult.StatusCode != StatusCodes.Good)
+ {
+ result = readResult.StatusCode;
+ }
+ }
+ else
+ {
+ // simple async read — framework owns post-processing.
+ AttributeSimpleReadResult simpleResult = await onSimpleReadValueAsync(
+ context, this, cancellationToken)
+ .ConfigureAwait(false);
+
+ result = simpleResult.Result;
+ valueToRead = simpleResult.Value;
+ sourceTimestamp = cachedTimestamp == DateTimeUtc.MinValue
+ ? DateTimeUtc.Now
+ : cachedTimestamp;
+
+ if (ServiceResult.IsGood(result))
+ {
+ ServiceResult rangeResult = ApplyIndexRangeAndDataEncoding(
+ context, indexRange, dataEncoding, ref valueToRead);
+
+ if (ServiceResult.IsBad(rangeResult))
+ {
+ result = rangeResult;
+ }
+ else
+ {
+ if (CopyPolicy is VariableCopyPolicy.CopyOnRead or VariableCopyPolicy.Always)
+ {
+ valueToRead = CoreUtils.Clone(valueToRead);
+ }
+
+ if (cachedStatusCode != StatusCodes.Good)
+ {
+ result = cachedStatusCode;
+ }
+ }
+ }
+ }
+ }
+ }
+ catch (Exception e)
+ {
+ result = ServiceResult.Create(
+ e,
+ StatusCodes.BadUnexpectedError,
+ "Failed to read value attribute from node.");
+ valueToRead = Variant.Null;
+ sourceTimestamp = DateTimeUtc.MinValue;
+ }
+
+ // commit to the supplied DataValue, mirroring NodeState.ReadAttribute(DataValue).
+ value.SourceTimestamp = sourceTimestamp;
+ value.SourcePicoseconds = 0;
+
+ if (result != null && result != ServiceResult.Good)
+ {
+ value.StatusCode = result.StatusCode;
+ }
+ else
+ {
+ value.StatusCode = StatusCodes.Good;
+ }
+
+ value.WrappedValue = StatusCode.IsBad(value.StatusCode) ? Variant.Null : valueToRead;
+
+ return result;
+ }
+
+ ///
+ /// Asynchronous override of
+ /// .
+ /// Dispatches to or
+ /// when set; otherwise falls
+ /// through to the synchronous write flow under lock(this).
+ ///
+ public override async ValueTask WriteAttributeAsync(
+ ISystemContext context,
+ uint attributeId,
+ NumericRange indexRange,
+ DataValue value,
+ CancellationToken cancellationToken = default)
+ {
+ NodeValueWriteEventHandlerAsync onWriteValueAsync = OnWriteValueAsync;
+ NodeValueSimpleWriteEventHandlerAsync onSimpleWriteValueAsync = OnSimpleWriteValueAsync;
+
+ if (attributeId != Attributes.Value ||
+ (onWriteValueAsync == null && onSimpleWriteValueAsync == null))
+ {
+ return await base.WriteAttributeAsync(
+ context, attributeId, indexRange, value, cancellationToken)
+ .ConfigureAwait(false);
+ }
+
+ if (value == null)
+ {
+ return ServiceResult.Create(
+ StatusCodes.BadStructureMissing,
+ "DataValue missing");
+ }
+
+ if (value.ServerTimestamp != DateTimeUtc.MinValue)
+ {
+ return ServiceResult.Create(
+ StatusCodes.BadWriteNotSupported,
+ "Cannot write to server timestamp");
+ }
+
+ try
+ {
+ // snapshot access levels under the lock.
+ uint accessLevel;
+ byte userAccessLevel;
+ // TODO: introduce a dedicated private lock object on NodeState
+ // — see the sibling note in ReadAttributeAsync for the rationale.
+#pragma warning disable CA2002 // Do not lock on objects with weak identity
+ lock (this)
+#pragma warning restore CA2002
+ {
+ accessLevel = m_accessLevel;
+ userAccessLevel = m_userAccessLevel;
+ }
+
+ if ((accessLevel & AccessLevels.CurrentWrite) == 0)
+ {
+ return StatusCodes.BadNotWritable;
+ }
+
+ OnReadUserAccessLevel?.Invoke(context, this, ref userAccessLevel);
+
+ if ((userAccessLevel & AccessLevels.CurrentWrite) == 0)
+ {
+ return StatusCodes.BadUserAccessDenied;
+ }
+
+ Variant valueToWrite = value.WrappedValue;
+ StatusCode statusCode = value.StatusCode;
+ DateTimeUtc sourceTimestamp = value.SourceTimestamp;
+
+ if (onWriteValueAsync != null)
+ {
+ AttributeWriteResult writeResult = await onWriteValueAsync(
+ context, this, indexRange, valueToWrite, cancellationToken)
+ .ConfigureAwait(false);
+
+ if (ServiceResult.IsBad(writeResult.Result))
+ {
+ return writeResult.Result;
+ }
+
+ DateTimeUtc effectiveTimestamp = sourceTimestamp == DateTimeUtc.MinValue
+ ? DateTimeUtc.Now
+ : sourceTimestamp;
+
+#pragma warning disable CA2002 // Do not lock on objects with weak identity
+ lock (this)
+#pragma warning restore CA2002
+ {
+ m_value = valueToWrite;
+ m_statusCode = statusCode;
+ m_timestamp = effectiveTimestamp;
+ ChangeMasks |= NodeStateChangeMasks.Value;
+ }
+
+ return writeResult.Result;
+ }
+
+ // simple async write path mirrors OnSimpleWriteValue:
+ // index-range writes are not supported through this hook.
+ if (!indexRange.IsNull)
+ {
+ return StatusCodes.BadIndexRangeInvalid;
+ }
+
+ if (sourceTimestamp == DateTimeUtc.MinValue)
+ {
+ sourceTimestamp = DateTimeUtc.Now;
+ }
+
+ if (CopyPolicy is VariableCopyPolicy.CopyOnWrite or VariableCopyPolicy.Always)
+ {
+ valueToWrite = CoreUtils.Clone(valueToWrite);
+ }
+
+ AttributeWriteResult simpleResult = await onSimpleWriteValueAsync(
+ context, this, valueToWrite, cancellationToken)
+ .ConfigureAwait(false);
+
+ if (ServiceResult.IsBad(simpleResult.Result))
+ {
+ return simpleResult.Result;
+ }
+
+#pragma warning disable CA2002 // Do not lock on objects with weak identity
+ lock (this)
+#pragma warning restore CA2002
+ {
+ m_value = valueToWrite;
+ m_statusCode = statusCode;
+ m_timestamp = sourceTimestamp;
+ ChangeMasks |= NodeStateChangeMasks.Value;
+ }
+
+ return simpleResult.Result;
+ }
+ catch (Exception e)
+ {
+ return ServiceResult.Create(
+ e,
+ StatusCodes.BadUnexpectedError,
+ "Failed to write value attribute.");
+ }
+ }
+
private Variant m_value;
private DateTimeUtc m_timestamp;
private bool m_valueTouched;
diff --git a/Stack/Opc.Ua.Types/State/NodeState.cs b/Stack/Opc.Ua.Types/State/NodeState.cs
index 5405662708..0876dc20c4 100644
--- a/Stack/Opc.Ua.Types/State/NodeState.cs
+++ b/Stack/Opc.Ua.Types/State/NodeState.cs
@@ -32,6 +32,7 @@
using System.IO;
using System.Linq;
using System.Threading;
+using System.Threading.Tasks;
using System.Xml;
using Opc.Ua.Types;
@@ -3696,6 +3697,50 @@ public virtual ServiceResult ReadAttribute(
return result;
}
+ ///
+ /// Asynchronous sibling of
+ /// .
+ /// The default implementation simply wraps the synchronous call
+ /// inside a lock(this) so behaviour is bit-identical for
+ /// every that does not override it. Derived
+ /// types (notably ) can override
+ /// this method to dispatch to true asynchronous read hooks without
+ /// blocking a thread.
+ ///
+ /// The context for the current operation.
+ /// The attribute id.
+ /// The index range.
+ /// The data encoding.
+ /// The value to populate.
+ /// Cancellation token.
+ ///
+ /// An instance of the containing the
+ /// status code and diagnostic info for the operation.
+ ///
+ public virtual ValueTask ReadAttributeAsync(
+ ISystemContext context,
+ uint attributeId,
+ NumericRange indexRange,
+ QualifiedName dataEncoding,
+ DataValue value,
+ CancellationToken cancellationToken = default)
+ {
+ ServiceResult result;
+ // TODO: introduce a dedicated private lock object on NodeState —
+ // today's sync flow synchronises through `lock(source)` taken by
+ // external callers (e.g. CustomNodeManager2.Read), so the async
+ // path must lock on the same instance to preserve mutual
+ // exclusion. Switching to a private lock object requires
+ // updating every external `lock(source)` site.
+#pragma warning disable CA2002 // Do not lock on objects with weak identity
+ lock (this)
+#pragma warning restore CA2002
+ {
+ result = ReadAttribute(context, attributeId, indexRange, dataEncoding, value);
+ }
+ return new ValueTask(result);
+ }
+
///
/// Reads the value for any non-value attribute.
///
@@ -3999,6 +4044,40 @@ public ServiceResult WriteAttribute(
}
}
+ ///
+ /// Asynchronous sibling of
+ /// .
+ /// The default implementation wraps the synchronous call inside a
+ /// lock(this) so behaviour is bit-identical for every
+ /// that does not override it. Derived
+ /// types (notably ) can override
+ /// this method to dispatch to true asynchronous write hooks
+ /// without blocking a thread.
+ ///
+ /// The context for the current operation.
+ /// The attribute id.
+ /// The index range.
+ /// The value to write.
+ /// Cancellation token.
+ public virtual ValueTask WriteAttributeAsync(
+ ISystemContext context,
+ uint attributeId,
+ NumericRange indexRange,
+ DataValue value,
+ CancellationToken cancellationToken = default)
+ {
+ ServiceResult result;
+ // TODO: introduce a dedicated private lock object on NodeState —
+ // see the sibling note in ReadAttributeAsync for the rationale.
+#pragma warning disable CA2002 // Do not lock on objects with weak identity
+ lock (this)
+#pragma warning restore CA2002
+ {
+ result = WriteAttribute(context, attributeId, indexRange, value);
+ }
+ return new ValueTask(result);
+ }
+
///
/// Write the value for any non-value attribute.
///
@@ -5340,6 +5419,99 @@ public delegate ServiceResult NodeValueEventHandler(
ref StatusCode statusCode,
ref DateTimeUtc timestamp);
+ ///
+ /// Result returned by an asynchronous full Value-attribute read hook
+ /// (). Carries the value plus
+ /// its status code and source timestamp; the framework writes all three
+ /// onto the supplied when the operation
+ /// succeeds. Allocated on the stack — no per-call allocation.
+ ///
+ ///
+ /// Operation result.
+ /// short-circuits the rest of the read pipeline.
+ ///
+ /// The value to return to the caller.
+ /// Status code to attach to the value.
+ ///
+ /// Source timestamp to attach.
+ /// causes the framework to substitute the current UTC time, mirroring
+ /// the synchronous behavior.
+ ///
+ public readonly record struct AttributeReadResult(
+ ServiceResult Result,
+ Variant Value,
+ StatusCode StatusCode,
+ DateTimeUtc SourceTimestamp);
+
+ ///
+ /// Result returned by an asynchronous simple Value-attribute read hook
+ /// (). Carries only the
+ /// value; the framework reuses the variable's cached status code and
+ /// timestamp and runs the standard index-range / data-encoding
+ /// post-processing — matching the synchronous
+ /// pipeline.
+ ///
+ /// Operation result.
+ /// The value to return to the caller.
+ public readonly record struct AttributeSimpleReadResult(
+ ServiceResult Result,
+ Variant Value);
+
+ ///
+ /// Result returned by an asynchronous Value-attribute write hook
+ /// ( /
+ /// ).
+ ///
+ /// Operation result.
+ public readonly record struct AttributeWriteResult(
+ ServiceResult Result);
+
+ ///
+ /// Asynchronous sibling of for the
+ /// read direction. Returns a instead
+ /// of writing to ref parameters because ref cannot cross
+ /// an await.
+ ///
+ public delegate ValueTask NodeValueEventHandlerAsync(
+ ISystemContext context,
+ NodeState node,
+ NumericRange indexRange,
+ QualifiedName dataEncoding,
+ CancellationToken cancellationToken);
+
+ ///
+ /// Asynchronous sibling of
+ /// for the read direction.
+ ///
+ public delegate ValueTask NodeValueSimpleEventHandlerAsync(
+ ISystemContext context,
+ NodeState node,
+ CancellationToken cancellationToken);
+
+ ///
+ /// Asynchronous sibling of for the
+ /// write direction. The hook receives the value being written and
+ /// returns only a status; on success the framework updates the
+ /// variable's cached value and timestamp to mirror the synchronous
+ /// path.
+ ///
+ public delegate ValueTask NodeValueWriteEventHandlerAsync(
+ ISystemContext context,
+ NodeState node,
+ NumericRange indexRange,
+ Variant value,
+ CancellationToken cancellationToken);
+
+ ///
+ /// Asynchronous sibling of
+ /// for the write direction.
+ ///
+ public delegate ValueTask NodeValueSimpleWriteEventHandlerAsync(
+ ISystemContext context,
+ NodeState node,
+ Variant value,
+ CancellationToken cancellationToken);
+
///
/// Stores a reference from a node in the instance hierarchy.
///
diff --git a/Tests/Opc.Ua.Aot.Tests/BoilerNodeManagerAotTests.cs b/Tests/Opc.Ua.Aot.Tests/BoilerNodeManagerAotTests.cs
index ab3479387f..a4bf625f7c 100644
--- a/Tests/Opc.Ua.Aot.Tests/BoilerNodeManagerAotTests.cs
+++ b/Tests/Opc.Ua.Aot.Tests/BoilerNodeManagerAotTests.cs
@@ -96,6 +96,57 @@ public async Task PipeFlowOnReadCallbackProducesValueInRange()
await Assert.That(value).IsBetween(75.0 - 1e-9, 125.0 + 1e-9);
}
+ [Test]
+ public async Task TypedBuilderWiredLevelMeasurementProducesValueInRange()
+ {
+ // Wired via Configure(IBoilerNodeManagerBuilder) using
+ // builder.Boilers.Boiler__1.LCX001.Measurement.OnRead(...) —
+ // exercises the source-generated typed traversal end-to-end
+ // through the AOT-compiled binary.
+ NodeId measurement = await ResolveBoilerVariableAsync(
+ "LCX001", "Measurement").ConfigureAwait(false);
+
+ DataValue dv = await fixture.Session.ReadValueAsync(
+ measurement, CancellationToken.None).ConfigureAwait(false);
+
+ await Assert.That(StatusCode.IsGood(dv.StatusCode)).IsTrue();
+ double value = dv.GetValue(double.NaN);
+ // Configure-wired typed OnRead returns 50 + 10*cos(t*0.05).
+ await Assert.That(value).IsBetween(40.0 - 1e-9, 60.0 + 1e-9);
+ }
+
+ [Test]
+ public async Task TypedBuilderWiredHaltMethodCanBeCalled()
+ {
+ // Wired via Configure(IBoilerNodeManagerBuilder) using
+ // builder.Boilers.Boiler__1.Simulation.Halt.OnCall(async ct =>
+ // ...) — exercises the typed-traversal async OnCall overload
+ // end-to-end through AsyncCustomNodeManager.CallAsync.
+ NodeId simulationObject = await ResolveBoilerObjectAsync(
+ "Simulation").ConfigureAwait(false);
+ NodeId haltMethod = await ResolveBoilerObjectAsync(
+ "Simulation", "Halt").ConfigureAwait(false);
+
+ // CallAsync (extension on ISessionClient) throws on a bad
+ // status, so a successful return is itself the assertion that
+ // the typed async OnCall thunk ran. The Halt method declares
+ // no input or output arguments.
+ ArrayOf outputs = await fixture.Session.CallAsync(
+ simulationObject,
+ haltMethod,
+ CancellationToken.None).ConfigureAwait(false);
+
+ await Assert.That(outputs.Count).IsEqualTo(0);
+ }
+
+ ///
+ /// Resolves an arbitrary node (object or method) under
+ /// Boilers/Boiler #1 using the same browse-path technique as
+ /// .
+ ///
+ private Task ResolveBoilerObjectAsync(params string[] tail)
+ => ResolveBoilerVariableAsync(tail);
+
///
/// Walks the boiler instance tree starting from the well-known
/// Boilers/Boiler #1 root (in the boiler namespace) using
diff --git a/Tests/Opc.Ua.Aot.Tests/CalculatorNodeManagerAotTests.cs b/Tests/Opc.Ua.Aot.Tests/CalculatorNodeManagerAotTests.cs
new file mode 100644
index 0000000000..5d73067147
--- /dev/null
+++ b/Tests/Opc.Ua.Aot.Tests/CalculatorNodeManagerAotTests.cs
@@ -0,0 +1,361 @@
+/* ========================================================================
+ * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+extern alias calcsample;
+
+using Microsoft.Extensions.Logging;
+using Opc.Ua.Client;
+using Opc.Ua.Server;
+using TUnit.Core.Interfaces;
+
+namespace Opc.Ua.Aot.Tests
+{
+ ///
+ /// AOT smoke tests that verify the source-generated
+ /// Calc.CalcNodeManagerFactory emitted by the
+ /// [NodeManager] attribute on
+ /// (in the MinimalCalcServer
+ /// sample) loads the calculator address space, registers its
+ /// namespace, and dispatches each of the three typed fluent
+ /// OnCall(...) overloads — sync int+int→int, async
+ /// double+double→double, and sync string+string→string — wired in
+ /// CalcNodeManager.Configure.cs. Together these tests cover
+ /// the generator's typed input-unpack (Variant.TryGetValue<T>),
+ /// output-box (Variant.From<T>), and async dispatch paths
+ /// against the AOT-compiled binary.
+ ///
+ [ClassDataSource(Shared = SharedType.PerTestSession)]
+ public class CalculatorNodeManagerAotTests(CalculatorAotFixture fixture)
+ {
+ private const string kCalcNamespaceUri =
+ "http://opcfoundation.org/UA/Calc/";
+
+ [Test]
+ public async Task CalcNamespaceIsRegistered()
+ {
+ DataValue nsArray = await fixture.Session.ReadValueAsync(
+ VariableIds.Server_NamespaceArray,
+ CancellationToken.None).ConfigureAwait(false);
+
+ await Assert.That(StatusCode.IsGood(nsArray.StatusCode)).IsTrue();
+ string[] uris = nsArray.GetValue(null);
+ await Assert.That(uris).IsNotNull();
+ await Assert.That(uris).Contains(kCalcNamespaceUri);
+ }
+
+ [Test]
+ public async Task AddMethodReturnsSum()
+ {
+ // Wired via Configure(ICalcNodeManagerBuilder) using
+ // builder.Calculator.Add.OnCall((int a, int b) => a + b) —
+ // exercises the typed sync OnCall overload end-to-end with
+ // primitive value-type inputs and output.
+ NodeId calculator = await ResolveCalculatorNodeAsync()
+ .ConfigureAwait(false);
+ NodeId addMethod = await ResolveCalculatorNodeAsync("Add")
+ .ConfigureAwait(false);
+
+ ArrayOf outputs = await fixture.Session.CallAsync(
+ calculator,
+ addMethod,
+ CancellationToken.None,
+ new Variant(2),
+ new Variant(3)).ConfigureAwait(false);
+
+ await Assert.That(outputs.Count).IsEqualTo(1);
+ Variant single = outputs.ToList()[0];
+ await Assert.That(single.TypeInfo.BuiltInType)
+ .IsEqualTo(BuiltInType.Int32);
+ await Assert.That(single.TryGetValue(out int sum)).IsTrue();
+ await Assert.That(sum).IsEqualTo(5);
+ }
+
+ [Test]
+ public async Task MultiplyMethodReturnsProductAsync()
+ {
+ // Wired via Configure(ICalcNodeManagerBuilder) using
+ // builder.Calculator.Multiply.OnCall(async (double, double,
+ // CancellationToken) => ...) — exercises the typed async
+ // OnCall overload end-to-end through
+ // AsyncCustomNodeManager.CallAsync.
+ NodeId calculator = await ResolveCalculatorNodeAsync()
+ .ConfigureAwait(false);
+ NodeId multiplyMethod = await ResolveCalculatorNodeAsync("Multiply")
+ .ConfigureAwait(false);
+
+ ArrayOf outputs = await fixture.Session.CallAsync(
+ calculator,
+ multiplyMethod,
+ CancellationToken.None,
+ new Variant(0.5),
+ new Variant(4.0)).ConfigureAwait(false);
+
+ await Assert.That(outputs.Count).IsEqualTo(1);
+ Variant single = outputs.ToList()[0];
+ await Assert.That(single.TypeInfo.BuiltInType)
+ .IsEqualTo(BuiltInType.Double);
+ await Assert.That(single.TryGetValue(out double product)).IsTrue();
+ await Assert.That(product).IsEqualTo(2.0);
+ }
+
+ [Test]
+ public async Task ConcatMethodReturnsConcatenation()
+ {
+ // Wired via Configure(ICalcNodeManagerBuilder) using
+ // builder.Calculator.Concat.OnCall((string l, string r) =>
+ // ...) — exercises typed reference-type marshalling on both
+ // input arguments and the return value.
+ NodeId calculator = await ResolveCalculatorNodeAsync()
+ .ConfigureAwait(false);
+ NodeId concatMethod = await ResolveCalculatorNodeAsync("Concat")
+ .ConfigureAwait(false);
+
+ ArrayOf outputs = await fixture.Session.CallAsync(
+ calculator,
+ concatMethod,
+ CancellationToken.None,
+ new Variant("foo"),
+ new Variant("bar")).ConfigureAwait(false);
+
+ await Assert.That(outputs.Count).IsEqualTo(1);
+ Variant single = outputs.ToList()[0];
+ await Assert.That(single.TypeInfo.BuiltInType)
+ .IsEqualTo(BuiltInType.String);
+ await Assert.That(single.TryGetValue(out string concatenated)).IsTrue();
+ await Assert.That(concatenated).IsEqualTo("foobar");
+ }
+
+ ///
+ /// Walks the calculator instance tree starting from the
+ /// well-known Calculator root (in the calc namespace)
+ /// using TranslateBrowsePathsToNodeIds so the tests do not
+ /// hard-code any generated NodeId.
+ ///
+ private async Task ResolveCalculatorNodeAsync(
+ params string[] tail)
+ {
+ ushort nsIndex = (ushort)fixture.Session.NamespaceUris
+ .GetIndex(kCalcNamespaceUri);
+ await Assert.That(nsIndex).IsGreaterThan((ushort)0);
+
+ var elements = new List
+ {
+ new()
+ {
+ ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences,
+ IsInverse = false,
+ IncludeSubtypes = true,
+ TargetName = new QualifiedName("Calculator", nsIndex)
+ }
+ };
+ foreach (string segment in tail)
+ {
+ elements.Add(new RelativePathElement
+ {
+ ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences,
+ IsInverse = false,
+ IncludeSubtypes = true,
+ TargetName = new QualifiedName(segment, nsIndex)
+ });
+ }
+
+ var browsePaths = new List
+ {
+ new()
+ {
+ StartingNode = ObjectIds.ObjectsFolder,
+ RelativePath = new RelativePath
+ {
+ Elements = elements.ToArrayOf()
+ }
+ }
+ };
+
+ TranslateBrowsePathsToNodeIdsResponse response =
+ await fixture.Session.TranslateBrowsePathsToNodeIdsAsync(
+ null, browsePaths.ToArrayOf(),
+ CancellationToken.None).ConfigureAwait(false);
+
+ await Assert.That(response.Results.Count).IsEqualTo(1);
+ BrowsePathResult result = response.Results.ToList()[0];
+ await Assert.That(StatusCode.IsGood(result.StatusCode)).IsTrue();
+ await Assert.That(result.Targets.Count).IsGreaterThan(0);
+
+ return ExpandedNodeId.ToNodeId(
+ result.Targets.ToList()[0].TargetId,
+ fixture.Session.NamespaceUris);
+ }
+ }
+
+ ///
+ /// Per-test-session fixture that boots a NativeAOT-friendly server
+ /// hosting the source-generated CalcNodeManagerFactory and
+ /// connects an anonymous client session to it.
+ ///
+ public sealed class CalculatorAotFixture : IAsyncInitializer, IAsyncDisposable
+ {
+ public AotServerFixture ServerFixture { get; private set; }
+ public Client.ISession Session { get; private set; }
+ public string ServerUrl { get; private set; }
+ public ITelemetryContext Telemetry { get; private set; }
+
+ public async Task InitializeAsync()
+ {
+ Telemetry = DefaultTelemetry.Create(builder =>
+ builder.SetMinimumLevel(LogLevel.Warning));
+
+ ServerFixture = new AotServerFixture(
+ t => new CalculatorTestServer(t), Telemetry)
+ {
+ AutoAccept = true,
+ SecurityNone = true
+ };
+ await ServerFixture.LoadConfigurationAsync(
+ Path.Combine(Directory.GetCurrentDirectory(), "calc-pki"))
+ .ConfigureAwait(false);
+ await ServerFixture.StartAsync().ConfigureAwait(false);
+
+ ServerUrl = $"opc.tcp://localhost:{ServerFixture.Port}/" +
+ nameof(CalculatorTestServer);
+
+ m_pkiRoot = Path.Combine(
+ Path.GetTempPath(), "OpcUaAotTests", "calc-client-pki");
+
+ m_clientConfiguration = new ApplicationConfiguration(Telemetry)
+ {
+ ApplicationName = "CalculatorAotTestClient",
+ ApplicationUri = "urn:localhost:OPCFoundation:CalculatorAotTestClient",
+ ApplicationType = ApplicationType.Client,
+ SecurityConfiguration = new SecurityConfiguration
+ {
+ ApplicationCertificate = new CertificateIdentifier
+ {
+ StoreType = CertificateStoreType.Directory,
+ StorePath = Path.Combine(m_pkiRoot, "own"),
+ SubjectName = "CN=CalculatorAotTestClient, O=OPC Foundation"
+ },
+ TrustedIssuerCertificates = new CertificateTrustList
+ {
+ StoreType = CertificateStoreType.Directory,
+ StorePath = Path.Combine(m_pkiRoot, "issuer")
+ },
+ TrustedPeerCertificates = new CertificateTrustList
+ {
+ StoreType = CertificateStoreType.Directory,
+ StorePath = Path.Combine(m_pkiRoot, "trusted")
+ },
+ RejectedCertificateStore = new CertificateTrustList
+ {
+ StoreType = CertificateStoreType.Directory,
+ StorePath = Path.Combine(m_pkiRoot, "rejected")
+ },
+ AutoAcceptUntrustedCertificates = true
+ },
+ TransportQuotas = new TransportQuotas
+ {
+ MaxMessageSize = 4 * 1024 * 1024
+ },
+ ClientConfiguration = new ClientConfiguration(),
+ ServerConfiguration = new ServerConfiguration()
+ };
+ await m_clientConfiguration.ValidateAsync(
+ ApplicationType.Client).ConfigureAwait(false);
+ m_clientConfiguration.CertificateManager ??= CertificateManagerFactory.Create(
+ m_clientConfiguration.SecurityConfiguration, Telemetry);
+ m_clientConfiguration.CertificateManager.AcceptError = static (cert, err) => true;
+
+ EndpointDescription endpointDescription =
+ await CoreClientUtils.SelectEndpointAsync(
+ m_clientConfiguration, ServerUrl, useSecurity: false,
+ Telemetry, CancellationToken.None).ConfigureAwait(false);
+ var configuredEndpoint = new ConfiguredEndpoint(
+ null, endpointDescription,
+ EndpointConfiguration.Create(m_clientConfiguration));
+
+ var sessionFactory = new DefaultSessionFactory(Telemetry);
+#pragma warning disable CA2000 // Dispose objects before losing scope
+ Session = await sessionFactory.CreateAsync(
+ m_clientConfiguration,
+ configuredEndpoint,
+ updateBeforeConnect: false,
+ sessionName: "CalculatorAotTest",
+ sessionTimeout: 60000,
+ identity: new UserIdentity(new AnonymousIdentityToken()),
+ preferredLocales: default,
+ ct: CancellationToken.None).ConfigureAwait(false);
+#pragma warning restore CA2000 // Dispose objects before losing scope
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ if (Session != null)
+ {
+ await Session.CloseAsync(CancellationToken.None)
+ .ConfigureAwait(false);
+ await Session.DisposeAsync().ConfigureAwait(false);
+ Session = null;
+ }
+ if (m_clientConfiguration?.CertificateManager is IDisposable disposableManager)
+ {
+ disposableManager.Dispose();
+ m_clientConfiguration.CertificateManager = null;
+ }
+ if (ServerFixture != null)
+ {
+ await ServerFixture.StopAsync().ConfigureAwait(false);
+ ServerFixture = null;
+ }
+ GC.SuppressFinalize(this);
+ }
+
+ private ApplicationConfiguration m_clientConfiguration;
+ private string m_pkiRoot;
+ }
+
+ ///
+ /// Public subclass that registers the
+ /// source-generated .
+ /// Mirrors the implicit hosting that AddNodeManager sets up
+ /// in MinimalCalcServer's Program.cs but is exposed as
+ /// public so can host it.
+ ///
+ public sealed class CalculatorTestServer : StandardServer
+ {
+ public CalculatorTestServer(ITelemetryContext telemetry)
+ : base(telemetry)
+ {
+ }
+
+ protected override void OnServerStarting(ApplicationConfiguration configuration)
+ {
+ base.OnServerStarting(configuration);
+ AddNodeManager(new calcsample::Calc.CalcNodeManagerFactory());
+ }
+ }
+}
diff --git a/Tests/Opc.Ua.Aot.Tests/Opc.Ua.Aot.Tests.csproj b/Tests/Opc.Ua.Aot.Tests/Opc.Ua.Aot.Tests.csproj
index d1cecf0068..daa60e92cc 100644
--- a/Tests/Opc.Ua.Aot.Tests/Opc.Ua.Aot.Tests.csproj
+++ b/Tests/Opc.Ua.Aot.Tests/Opc.Ua.Aot.Tests.csproj
@@ -24,6 +24,9 @@
boilersample
+
+ calcsample
+
diff --git a/Tests/Opc.Ua.Aot.Tests/PublishedEventsAotTests.cs b/Tests/Opc.Ua.Aot.Tests/PublishedEventsAotTests.cs
new file mode 100644
index 0000000000..b1ea0e2256
--- /dev/null
+++ b/Tests/Opc.Ua.Aot.Tests/PublishedEventsAotTests.cs
@@ -0,0 +1,204 @@
+/* ========================================================================
+ * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using Opc.Ua.Client;
+
+namespace Opc.Ua.Aot.Tests
+{
+ ///
+ /// AOT integration tests that verify the source-generated typed
+ /// Publish<TEvent> overload on the boiler's
+ /// DrumX001 notifier wrapper actually wires an
+ ///
+ /// event source through the runtime
+ /// EventSourceRegistry and dispatches events to
+ /// monitored items under NativeAOT constraints (no JIT, no
+ /// reflection).
+ ///
+ [ClassDataSource(Shared = SharedType.PerTestSession)]
+ public class PublishedEventsAotTests(BoilerAotFixture fixture)
+ {
+ private const string kBoilerNamespaceUri =
+ "http://opcfoundation.org/UA/Boiler/";
+
+ [Test]
+ public async Task DrumHeartbeatEventsArriveAtMonitoredItem()
+ {
+ NodeId drumNodeId = await ResolveBoilerNodeAsync(
+ "DrumX001").ConfigureAwait(false);
+
+ var eventFilter = new EventFilter();
+ eventFilter.AddSelectClause(
+ ObjectTypeIds.BaseEventType, QualifiedName.From("EventId"));
+ eventFilter.AddSelectClause(
+ ObjectTypeIds.BaseEventType, QualifiedName.From("EventType"));
+ eventFilter.AddSelectClause(
+ ObjectTypeIds.BaseEventType, QualifiedName.From("SourceName"));
+ eventFilter.AddSelectClause(
+ ObjectTypeIds.BaseEventType, QualifiedName.From("Severity"));
+ eventFilter.AddSelectClause(
+ ObjectTypeIds.BaseEventType, QualifiedName.From("Message"));
+
+ using var subscription = new Subscription(fixture.Session.DefaultSubscription)
+ {
+ DisplayName = "AotDrumHeartbeats",
+ PublishingEnabled = true,
+ PublishingInterval = 250,
+ KeepAliveCount = 10
+ };
+
+ fixture.Session.AddSubscription(subscription);
+ await subscription.CreateAsync(CancellationToken.None)
+ .ConfigureAwait(false);
+
+ try
+ {
+ var received = new TaskCompletionSource(
+ TaskCreationOptions.RunContinuationsAsynchronously);
+
+ var eventItem = new MonitoredItem(subscription.DefaultItem)
+ {
+ StartNodeId = drumNodeId,
+ AttributeId = Attributes.EventNotifier,
+ DisplayName = "DrumHeartbeats",
+ Filter = eventFilter,
+ QueueSize = 16
+ };
+
+ eventItem.Notification += (item, args) =>
+ {
+ if (args.NotificationValue is EventFieldList fields)
+ {
+ received.TrySetResult(fields);
+ }
+ };
+
+ subscription.AddItem(eventItem);
+ await subscription.ApplyChangesAsync(CancellationToken.None)
+ .ConfigureAwait(false);
+
+ using var cts = new CancellationTokenSource(
+ TimeSpan.FromSeconds(15));
+ using (cts.Token.Register(
+ () => received.TrySetCanceled(cts.Token)))
+ {
+ EventFieldList fields = await received.Task
+ .ConfigureAwait(false);
+
+ await Assert.That(fields.EventFields.Count)
+ .IsEqualTo(eventFilter.SelectClauses.Count);
+
+ List values = fields.EventFields.ToList();
+ string sourceName = values[2].GetString();
+ ushort severity = values[3].GetUInt16();
+ LocalizedText message = values[4].GetLocalizedText();
+
+ await Assert.That(sourceName).IsEqualTo("DrumX001");
+ await Assert.That((int)severity)
+ .IsEqualTo((int)EventSeverity.Medium);
+ await Assert.That(message.IsNull).IsFalse();
+ await Assert.That(message.Text)
+ .StartsWith("Drum heartbeat #");
+ }
+ }
+ finally
+ {
+ await fixture.Session.RemoveSubscriptionAsync(subscription)
+ .ConfigureAwait(false);
+ }
+ }
+
+ ///
+ /// Walks the boiler instance tree starting from
+ /// Boilers/Boiler #1 in the boiler namespace using
+ /// TranslateBrowsePathsToNodeIds.
+ ///
+ private async Task ResolveBoilerNodeAsync(
+ params string[] tail)
+ {
+ ushort nsIndex = (ushort)fixture.Session.NamespaceUris
+ .GetIndex(kBoilerNamespaceUri);
+ await Assert.That(nsIndex).IsGreaterThan((ushort)0);
+
+ var elements = new List
+ {
+ new()
+ {
+ ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences,
+ IsInverse = false,
+ IncludeSubtypes = true,
+ TargetName = new QualifiedName("Boilers", nsIndex)
+ },
+ new()
+ {
+ ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences,
+ IsInverse = false,
+ IncludeSubtypes = true,
+ TargetName = new QualifiedName("Boiler #1", nsIndex)
+ }
+ };
+ foreach (string segment in tail)
+ {
+ elements.Add(new RelativePathElement
+ {
+ ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences,
+ IsInverse = false,
+ IncludeSubtypes = true,
+ TargetName = new QualifiedName(segment, nsIndex)
+ });
+ }
+
+ var browsePaths = new List
+ {
+ new()
+ {
+ StartingNode = ObjectIds.ObjectsFolder,
+ RelativePath = new RelativePath
+ {
+ Elements = elements.ToArrayOf()
+ }
+ }
+ };
+
+ TranslateBrowsePathsToNodeIdsResponse response =
+ await fixture.Session.TranslateBrowsePathsToNodeIdsAsync(
+ null, browsePaths.ToArrayOf(),
+ CancellationToken.None).ConfigureAwait(false);
+
+ await Assert.That(response.Results.Count).IsEqualTo(1);
+ BrowsePathResult result = response.Results.ToList()[0];
+ await Assert.That(StatusCode.IsGood(result.StatusCode)).IsTrue();
+ await Assert.That(result.Targets.Count).IsGreaterThan(0);
+
+ return ExpandedNodeId.ToNodeId(
+ result.Targets.ToList()[0].TargetId,
+ fixture.Session.NamespaceUris);
+ }
+ }
+}
diff --git a/Tests/Opc.Ua.Server.Tests/Fluent/NodeManagerBuilderTests.cs b/Tests/Opc.Ua.Server.Tests/Fluent/NodeManagerBuilderTests.cs
index 47cac4d2d3..54913c32a4 100644
--- a/Tests/Opc.Ua.Server.Tests/Fluent/NodeManagerBuilderTests.cs
+++ b/Tests/Opc.Ua.Server.Tests/Fluent/NodeManagerBuilderTests.cs
@@ -577,5 +577,78 @@ public void NodeFromTypeIdTypedThrowsBadTypeMismatch()
() => b.NodeFromTypeId(typeId));
Assert.That(ex.StatusCode, Is.EqualTo((uint)StatusCodes.BadTypeMismatch));
}
+
+ [Test]
+ public void ChildResolvesByBrowseName()
+ {
+ (NodeManagerBuilder b, BaseObjectState root, BaseDataVariableState v, _) = CreateBuilderWithGraph();
+
+ INodeBuilder rb = b.Node(root.NodeId);
+ INodeBuilder cb = rb.Child(v.BrowseName);
+
+ Assert.That(cb.Node, Is.SameAs(v));
+ Assert.That(cb.Builder, Is.SameAs(b));
+ }
+
+ [Test]
+ public void ChildTypedReturnsTypedBuilder()
+ {
+ (NodeManagerBuilder b, BaseObjectState root, BaseDataVariableState v, _) = CreateBuilderWithGraph();
+
+ INodeBuilder cb =
+ b.Node(root.NodeId).Child(v.BrowseName);
+
+ Assert.That(cb.Node, Is.SameAs(v));
+ }
+
+ [Test]
+ public void ChildTypedThrowsBadTypeMismatchForWrongType()
+ {
+ (NodeManagerBuilder b, BaseObjectState root, BaseDataVariableState v, _) = CreateBuilderWithGraph();
+
+ ServiceResultException ex = Assert.Throws(
+ () => b.Node(root.NodeId).Child(v.BrowseName));
+ Assert.That(ex.StatusCode, Is.EqualTo((uint)StatusCodes.BadTypeMismatch));
+ }
+
+ [Test]
+ public void ChildThrowsBadNodeIdUnknownForMissingBrowseName()
+ {
+ (NodeManagerBuilder b, BaseObjectState root, _, _) = CreateBuilderWithGraph();
+
+ ServiceResultException ex = Assert.Throws(
+ () => b.Node(root.NodeId).Child(new QualifiedName("Missing", kNs)));
+ Assert.That(ex.StatusCode, Is.EqualTo((uint)StatusCodes.BadNodeIdUnknown));
+ }
+
+ [Test]
+ public void ChildThrowsBadBrowseNameInvalidForNullBrowseName()
+ {
+ (NodeManagerBuilder b, BaseObjectState root, _, _) = CreateBuilderWithGraph();
+
+ ServiceResultException ex = Assert.Throws(
+ () => b.Node(root.NodeId).Child(QualifiedName.Null));
+ Assert.That(ex.StatusCode, Is.EqualTo((uint)StatusCodes.BadBrowseNameInvalid));
+ }
+
+ [Test]
+ public void VariableByBrowseNameReturnsTypedVariableBuilder()
+ {
+ (NodeManagerBuilder b, BaseObjectState root, BaseDataVariableState v, _) = CreateBuilderWithGraph();
+
+ IVariableBuilder vb = b.Node(root.NodeId).Variable(v.BrowseName);
+
+ Assert.That(vb.Node, Is.SameAs(v));
+ }
+
+ [Test]
+ public void VariableByBrowseNameThrowsBadTypeMismatchOnNonVariable()
+ {
+ (NodeManagerBuilder b, BaseObjectState root, _, MethodState m) = CreateBuilderWithGraph();
+
+ ServiceResultException ex = Assert.Throws(
+ () => b.Node(root.NodeId).Variable(m.BrowseName));
+ Assert.That(ex.StatusCode, Is.EqualTo((uint)StatusCodes.BadTypeMismatch));
+ }
}
}
diff --git a/Tests/Opc.Ua.Server.Tests/Fluent/PublishTests.cs b/Tests/Opc.Ua.Server.Tests/Fluent/PublishTests.cs
new file mode 100644
index 0000000000..51405db374
--- /dev/null
+++ b/Tests/Opc.Ua.Server.Tests/Fluent/PublishTests.cs
@@ -0,0 +1,838 @@
+/* ========================================================================
+ * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+// CA2000: per-test fixture lifecycle is managed by NUnit; managers and other
+// disposables are explicitly disposed in TearDown or by the using-block.
+#pragma warning disable CA2000
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Linq;
+using System.Runtime.CompilerServices;
+using System.Threading;
+using System.Threading.Channels;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+using Moq;
+using NUnit.Framework;
+using Opc.Ua.Server.Fluent;
+
+namespace Opc.Ua.Server.Tests.Fluent
+{
+ [TestFixture]
+ [Category("Fluent")]
+ [Parallelizable(ParallelScope.None)]
+ public class PublishTests
+ {
+ private const ushort kNs = 2;
+ private const string kNamespaceUri = "http://test.org/UA/Publish/";
+ // Generous timeout: reconcile/worker tasks run on the thread pool which
+ // can be starved when the broader test suite (e.g. AsyncCustomNodeManager
+ // tests with [Parallelizable(ParallelScope.All)]) saturates CPU. 15s
+ // keeps green runs under 1s while eliminating false negatives under load.
+ private static readonly TimeSpan s_signalTimeout = TimeSpan.FromSeconds(15);
+ private static readonly TimeSpan s_negativeWindow = TimeSpan.FromMilliseconds(250);
+
+ private Mock m_mockServer;
+ private ApplicationConfiguration m_configuration;
+ private Mock m_mockMasterNodeManager;
+ private NamespaceTable m_namespaceTable;
+ private MonitoredItemQueueFactory m_queueFactory;
+
+ [SetUp]
+ public void SetUp()
+ {
+ m_mockServer = new Mock();
+ m_mockMasterNodeManager = new Mock();
+ var mockConfigurationNodeManager = new Mock();
+
+ m_namespaceTable = new NamespaceTable();
+ m_namespaceTable.Append(kNamespaceUri);
+
+ m_mockServer.Setup(s => s.NamespaceUris).Returns(m_namespaceTable);
+ m_mockServer.Setup(s => s.ServerUris).Returns(new StringTable());
+ m_mockServer.Setup(s => s.TypeTree).Returns(new TypeTable(m_namespaceTable));
+ m_mockServer.Setup(s => s.Factory).Returns(EncodeableFactory.Create());
+ m_mockServer.Setup(s => s.NodeManager).Returns(m_mockMasterNodeManager.Object);
+ m_mockMasterNodeManager
+ .Setup(m => m.ConfigurationNodeManager)
+ .Returns(mockConfigurationNodeManager.Object);
+
+ var mockTelemetry = new Mock();
+ m_mockServer.Setup(s => s.Telemetry).Returns(mockTelemetry.Object);
+
+ m_queueFactory = new MonitoredItemQueueFactory(mockTelemetry.Object);
+ m_mockServer.Setup(s => s.MonitoredItemQueueFactory).Returns(m_queueFactory);
+
+ var defaultContext = new ServerSystemContext(m_mockServer.Object);
+ m_mockServer.Setup(s => s.DefaultSystemContext).Returns(defaultContext);
+
+ m_configuration = new ApplicationConfiguration
+ {
+ ServerConfiguration = new ServerConfiguration
+ {
+ MaxNotificationQueueSize = 100,
+ MaxDurableNotificationQueueSize = 200
+ }
+ };
+ }
+
+ [TearDown]
+ public void TearDown()
+ {
+ m_queueFactory?.Dispose();
+ }
+
+ #region Lazy / eager activation
+
+ [Test]
+ public async Task Publish_LazyDefault_DoesNotInvokeFactoryUntilEventsAreMonitoredAsync()
+ {
+ using TestablePublishManager manager = CreateManager();
+ BaseObjectState notifier = MakeNotifier(manager, "LazyNotifier");
+
+ var factoryStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ using var stopFactory = new CancellationTokenSource();
+
+ manager.EventSources.Register(
+ notifier,
+ (_, _, ct) => CountingStream(factoryStarted, ct),
+ options: null);
+
+ // Lazy mode: factory MUST NOT be invoked while no monitor is attached.
+ await Task.Delay(s_negativeWindow).ConfigureAwait(false);
+ Assert.That(factoryStarted.Task.IsCompleted, Is.False,
+ "Factory must not run before AreEventsMonitored flips on.");
+
+ // Flip the flag and signal — registry should activate within s_signalTimeout.
+ notifier.SetAreEventsMonitored(manager.SystemContext, true, false);
+ manager.EventSources.SignalReconcile();
+
+ await WaitForAsync(factoryStarted.Task).ConfigureAwait(false);
+ stopFactory.Cancel();
+ }
+
+ [Test]
+ public async Task Publish_AlwaysOn_StartsFactoryWithoutSubscribersAsync()
+ {
+ using TestablePublishManager manager = CreateManager();
+ BaseObjectState notifier = MakeNotifier(manager, "EagerNotifier");
+
+ var factoryStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ manager.EventSources.Register(
+ notifier,
+ (_, _, ct) => CountingStream(factoryStarted, ct),
+ new EventPublishOptions { AlwaysOn = true });
+
+ await WaitForAsync(factoryStarted.Task).ConfigureAwait(false);
+ Assert.That(notifier.AreEventsMonitored, Is.False,
+ "AlwaysOn must not require AreEventsMonitored to be true.");
+ }
+
+ [Test]
+ public async Task Publish_LazyMonitorThenUnmonitor_DeactivatesFactoryAsync()
+ {
+ using TestablePublishManager manager = CreateManager();
+ BaseObjectState notifier = MakeNotifier(manager, "Toggling");
+
+ var iteratorEntered = new TaskCompletionSource(
+ TaskCreationOptions.RunContinuationsAsynchronously);
+ var iteratorObservedCancel = new TaskCompletionSource(
+ TaskCreationOptions.RunContinuationsAsynchronously);
+
+ manager.EventSources.Register(
+ notifier,
+ (_, _, ct) => CancelObservingStream(iteratorEntered, iteratorObservedCancel, ct),
+ options: null);
+
+ notifier.SetAreEventsMonitored(manager.SystemContext, true, false);
+ manager.EventSources.SignalReconcile();
+
+ // Wait until the worker is actually inside the iterator before
+ // unmonitoring — otherwise the unsubscribe could race ahead and the
+ // cancel would be observed during enumerator setup, not the await.
+ await WaitForAsync(iteratorEntered.Task).ConfigureAwait(false);
+
+ notifier.SetAreEventsMonitored(manager.SystemContext, false, false);
+ manager.EventSources.SignalReconcile();
+
+ await WaitForAsync(iteratorObservedCancel.Task).ConfigureAwait(false);
+ }
+
+ #endregion
+
+ #region Event delivery
+
+ [Test]
+ public async Task Publish_ActivatedSource_DeliversEventsThroughOnReportEventAsync()
+ {
+ using TestablePublishManager manager = CreateManager();
+ BaseObjectState notifier = MakeNotifier(manager, "Delivering");
+
+ var observed = new ConcurrentQueue();
+ var observedCount = new AsyncCountdown(target: 3);
+ notifier.OnReportEvent = (_, _, e) =>
+ {
+ if (e is BaseEventState evt)
+ {
+ observed.Enqueue(evt);
+ observedCount.SignalOne();
+ }
+ };
+
+ Channel channel = Channel.CreateUnbounded();
+ manager.EventSources.Register(
+ notifier,
+ (_, _, ct) => channel.Reader.ReadAllAsync(ct),
+ new EventPublishOptions { AlwaysOn = true });
+
+ for (int i = 0; i < 3; i++)
+ {
+ await channel.Writer.WriteAsync(new BaseEventState(parent: null)).ConfigureAwait(false);
+ }
+
+ await WaitForAsync(observedCount.WaitAsync()).ConfigureAwait(false);
+ Assert.That(observed, Has.Count.EqualTo(3));
+ }
+
+ [Test]
+ public async Task Publish_DispatchedEvent_PopulatesDefaultsAsync()
+ {
+ using TestablePublishManager manager = CreateManager();
+ BaseObjectState notifier = MakeNotifier(manager, "Defaults");
+
+ var captured = new TaskCompletionSource(
+ TaskCreationOptions.RunContinuationsAsynchronously);
+ notifier.OnReportEvent = (_, _, e) =>
+ {
+ if (e is BaseEventState evt)
+ {
+ captured.TrySetResult(evt);
+ }
+ };
+
+ Channel channel = Channel.CreateUnbounded();
+ manager.EventSources.Register(
+ notifier,
+ (_, _, ct) => channel.Reader.ReadAllAsync(ct),
+ new EventPublishOptions { AlwaysOn = true });
+
+ await channel.Writer.WriteAsync(new BaseEventState(parent: null)).ConfigureAwait(false);
+
+ BaseEventState seen = await WaitForAsync(captured.Task).ConfigureAwait(false);
+ Assert.Multiple(() =>
+ {
+ Assert.That(seen.EventId, Is.Not.Null);
+ Assert.That(seen.EventId.Value.IsNull, Is.False);
+ Assert.That(seen.EventType, Is.Not.Null);
+ Assert.That(seen.EventType.Value.IsNull, Is.False);
+ Assert.That(seen.SourceNode, Is.Not.Null);
+ Assert.That(seen.SourceNode.Value, Is.EqualTo(notifier.NodeId));
+ Assert.That(seen.SourceName, Is.Not.Null);
+ Assert.That(seen.SourceName.Value, Is.EqualTo(notifier.BrowseName.Name));
+ Assert.That(seen.Time, Is.Not.Null);
+ Assert.That(seen.Time.Value.IsNull, Is.False);
+ Assert.That(seen.ReceiveTime, Is.Not.Null);
+ Assert.That(seen.Severity, Is.Not.Null);
+ Assert.That(seen.Severity.Value, Is.EqualTo((ushort)EventSeverity.Medium));
+ Assert.That(seen.Message, Is.Not.Null);
+ });
+ }
+
+ [Test]
+ public async Task Publish_DispatchedEvent_PreservesUserPopulatedFieldsAsync()
+ {
+ using TestablePublishManager manager = CreateManager();
+ BaseObjectState notifier = MakeNotifier(manager, "Custom");
+
+ var captured = new TaskCompletionSource(
+ TaskCreationOptions.RunContinuationsAsynchronously);
+ notifier.OnReportEvent = (_, _, e) =>
+ {
+ if (e is BaseEventState evt)
+ {
+ captured.TrySetResult(evt);
+ }
+ };
+
+ ByteString customEventId = Uuid.NewUuid().ToByteString();
+ var customSource = new NodeId("OtherSource", kNs);
+ const string kCustomSourceName = "AlternateName";
+ const ushort kCustomSeverity = 800;
+
+ BaseEventState authored = new BaseEventState(parent: null);
+ authored.EventId = PropertyState.With(authored, customEventId);
+ authored.SourceNode = PropertyState.With(authored, customSource);
+ authored.SourceName = PropertyState.With(authored, kCustomSourceName);
+ authored.Severity = PropertyState.With(authored, kCustomSeverity);
+
+ Channel channel = Channel.CreateUnbounded();
+ manager.EventSources.Register(
+ notifier,
+ (_, _, ct) => channel.Reader.ReadAllAsync(ct),
+ new EventPublishOptions { AlwaysOn = true });
+
+ await channel.Writer.WriteAsync(authored).ConfigureAwait(false);
+
+ BaseEventState seen = await WaitForAsync(captured.Task).ConfigureAwait(false);
+ Assert.Multiple(() =>
+ {
+ Assert.That(seen.EventId.Value, Is.EqualTo(customEventId));
+ Assert.That(seen.SourceNode.Value, Is.EqualTo(customSource));
+ Assert.That(seen.SourceName.Value, Is.EqualTo(kCustomSourceName));
+ Assert.That(seen.Severity.Value, Is.EqualTo(kCustomSeverity));
+ });
+ }
+
+ [Test]
+ public async Task Publish_SkipDefaultPopulation_LeavesFieldsUntouchedAsync()
+ {
+ using TestablePublishManager manager = CreateManager();
+ BaseObjectState notifier = MakeNotifier(manager, "SkipDefaults");
+
+ var captured = new TaskCompletionSource(
+ TaskCreationOptions.RunContinuationsAsynchronously);
+ notifier.OnReportEvent = (_, _, e) =>
+ {
+ if (e is BaseEventState evt)
+ {
+ captured.TrySetResult(evt);
+ }
+ };
+
+ Channel channel = Channel.CreateUnbounded();
+ manager.EventSources.Register(
+ notifier,
+ (_, _, ct) => channel.Reader.ReadAllAsync(ct),
+ new EventPublishOptions { AlwaysOn = true, SkipDefaultPopulation = true });
+
+ await channel.Writer.WriteAsync(new BaseEventState(parent: null)).ConfigureAwait(false);
+
+ BaseEventState seen = await WaitForAsync(captured.Task).ConfigureAwait(false);
+ Assert.Multiple(() =>
+ {
+ Assert.That(seen.EventId, Is.Null);
+ Assert.That(seen.EventType, Is.Null);
+ Assert.That(seen.SourceNode, Is.Null);
+ Assert.That(seen.SourceName, Is.Null);
+ Assert.That(seen.Time, Is.Null);
+ Assert.That(seen.ReceiveTime, Is.Null);
+ Assert.That(seen.Severity, Is.Null);
+ Assert.That(seen.Message, Is.Null);
+ });
+ }
+
+ #endregion
+
+ #region Errors and validation
+
+ [Test]
+ public async Task Publish_FactoryThrows_InvokesOnErrorAndStopsSourceAsync()
+ {
+ using TestablePublishManager manager = CreateManager();
+ BaseObjectState notifier = MakeNotifier(manager, "FactoryThrows");
+
+ var thrown = new InvalidOperationException("factory boom");
+ var captured = new TaskCompletionSource(
+ TaskCreationOptions.RunContinuationsAsynchronously);
+
+ manager.EventSources.Register(
+ notifier,
+ (_, _, _) => throw thrown,
+ new EventPublishOptions
+ {
+ AlwaysOn = true,
+ OnError = ex => captured.TrySetResult(ex)
+ });
+
+ Exception observed = await WaitForAsync(captured.Task).ConfigureAwait(false);
+ Assert.That(observed, Is.SameAs(thrown));
+ }
+
+ [Test]
+ public async Task Publish_IteratorThrows_InvokesOnErrorAsync()
+ {
+ using TestablePublishManager manager = CreateManager();
+ BaseObjectState notifier = MakeNotifier(manager, "IteratorThrows");
+
+ var thrown = new InvalidOperationException("iterator boom");
+ var captured = new TaskCompletionSource(
+ TaskCreationOptions.RunContinuationsAsynchronously);
+
+ manager.EventSources.Register(
+ notifier,
+ (_, _, ct) => ThrowingStream(thrown, ct),
+ new EventPublishOptions
+ {
+ AlwaysOn = true,
+ OnError = ex => captured.TrySetResult(ex)
+ });
+
+ Exception observed = await WaitForAsync(captured.Task).ConfigureAwait(false);
+ Assert.That(observed, Is.SameAs(thrown));
+ }
+
+ [Test]
+ public void Publish_DuplicateRegistration_ThrowsBadConfigurationError()
+ {
+ using TestablePublishManager manager = CreateManager();
+ BaseObjectState notifier = MakeNotifier(manager, "Duplicate");
+
+ manager.EventSources.Register(
+ notifier,
+ (_, _, ct) => EmptyStream(ct),
+ options: null);
+
+ ServiceResultException ex = Assert.Throws(() =>
+ manager.EventSources.Register(
+ notifier,
+ (_, _, ct) => EmptyStream(ct),
+ options: null));
+
+ Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadConfigurationError));
+ }
+
+ [Test]
+ public void Publish_NegativeCancellationTimeout_ThrowsArgumentOutOfRange()
+ {
+ using TestablePublishManager manager = CreateManager();
+ BaseObjectState notifier = MakeNotifier(manager, "BadTimeout");
+
+ Assert.Throws(() =>
+ manager.EventSources.Register(
+ notifier,
+ (_, _, ct) => EmptyStream(ct),
+ new EventPublishOptions { CancellationTimeout = TimeSpan.FromSeconds(-1) }));
+ }
+
+ [Test]
+ public void Publish_InfiniteCancellationTimeout_IsAccepted()
+ {
+ using TestablePublishManager manager = CreateManager();
+ BaseObjectState notifier = MakeNotifier(manager, "InfiniteTimeout");
+
+ Assert.DoesNotThrow(() =>
+ manager.EventSources.Register(
+ notifier,
+ (_, _, ct) => EmptyStream(ct),
+ new EventPublishOptions { CancellationTimeout = Timeout.InfiniteTimeSpan }));
+ }
+
+ [Test]
+ public void Publish_NullNotifier_ThrowsArgumentNull()
+ {
+ using TestablePublishManager manager = CreateManager();
+
+ Assert.Throws(() =>
+ manager.EventSources.Register(
+ notifier: null,
+ (_, _, ct) => EmptyStream(ct),
+ options: null));
+ }
+
+ [Test]
+ public void Publish_NullFactory_ThrowsArgumentNull()
+ {
+ using TestablePublishManager manager = CreateManager();
+ BaseObjectState notifier = MakeNotifier(manager, "NullFactory");
+
+ Assert.Throws(() =>
+ manager.EventSources.Register(
+ notifier,
+ factory: null,
+ options: null));
+ }
+
+ #endregion
+
+ #region Auto-promote and root-notifier
+
+ [Test]
+ public void Publish_AutoPromotesEventNotifierBit()
+ {
+ using TestablePublishManager manager = CreateManager();
+ BaseObjectState notifier = MakeNotifier(manager, "AutoPromote", eventNotifier: EventNotifiers.None);
+ Assert.That(notifier.EventNotifier, Is.EqualTo(EventNotifiers.None),
+ "Sanity: notifier started without SubscribeToEvents.");
+
+ manager.EventSources.Register(
+ notifier,
+ (_, _, ct) => EmptyStream(ct),
+ options: null);
+
+ Assert.That(
+ (notifier.EventNotifier & EventNotifiers.SubscribeToEvents),
+ Is.EqualTo(EventNotifiers.SubscribeToEvents),
+ "Publish must auto-promote SubscribeToEvents on the notifier.");
+ }
+
+ [Test]
+ public void Publish_RegisterAsRootNotifier_AddsToRootNotifierSet()
+ {
+ using TestablePublishManager manager = CreateManager();
+ BaseObjectState notifier = MakeNotifier(manager, "Root");
+
+ manager.EventSources.Register(
+ notifier,
+ (_, _, ct) => EmptyStream(ct),
+ new EventPublishOptions { RegisterAsRootNotifier = true });
+
+ Assert.That(manager.RootNotifiers, Contains.Key(notifier.NodeId));
+ }
+
+ #endregion
+
+ #region Lifecycle / dispose
+
+ [Test]
+ public async Task Dispose_CancelsActiveIteratorAsync()
+ {
+ TestablePublishManager manager = CreateManager();
+ BaseObjectState notifier = MakeNotifier(manager, "DisposeCancel");
+
+ var iteratorEntered = new TaskCompletionSource(
+ TaskCreationOptions.RunContinuationsAsynchronously);
+ var iteratorObservedCancel = new TaskCompletionSource(
+ TaskCreationOptions.RunContinuationsAsynchronously);
+
+ manager.EventSources.Register(
+ notifier,
+ (_, _, ct) => CancelObservingStream(iteratorEntered, iteratorObservedCancel, ct),
+ new EventPublishOptions { AlwaysOn = true });
+
+ // Wait until the worker is actually inside the iterator before
+ // disposing the manager so we observe cancel propagation, not setup.
+ await WaitForAsync(iteratorEntered.Task).ConfigureAwait(false);
+
+ manager.Dispose();
+
+ await WaitForAsync(iteratorObservedCancel.Task).ConfigureAwait(false);
+ }
+
+ #endregion
+
+ #region Extension method (Publish on builder)
+
+ [Test]
+ public void Publish_OnNonFluentManager_ThrowsBadConfigurationErrorWithManagerType()
+ {
+ using TestablePublishManager fluent = CreateManager();
+ BaseObjectState notifier = MakeNotifier(fluent, "WrongBase");
+
+ // Build a NodeManagerBuilder backed by a non-fluent (Mock) manager
+ // and feed in only the resolver for `notifier`. Publish must reject
+ // it because the registry was never attached.
+ var roots = new Dictionary { [notifier.BrowseName] = notifier };
+ var byId = new Dictionary { [notifier.NodeId] = notifier };
+
+ var nonFluentManager = new Mock();
+
+ var nonFluentBuilder = new NodeManagerBuilder(
+ fluent.SystemContext,
+ nodeManager: nonFluentManager.Object,
+ defaultNamespaceIndex: kNs,
+ rootResolver: q => roots.TryGetValue(q, out NodeState n) ? n : null,
+ nodeIdResolver: id => byId.TryGetValue(id, out NodeState n) ? n : null,
+ typeIdResolver: _ => []);
+
+ INodeBuilder nodeBuilder = nonFluentBuilder.Node(notifier.BrowseName.Name);
+ ServiceResultException ex = Assert.Throws(() =>
+ nodeBuilder.Publish(
+ (_, _, ct) => EmptyStream(ct)));
+
+ Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadConfigurationError));
+ Assert.That(ex.Message, Does.Contain("FluentNodeManagerBase"),
+ "Error message must reference the required base class.");
+ }
+
+ [Test]
+ public async Task Publish_FactoryOverloadOnAttachedBuilder_RegistersAndDeliversAsync()
+ {
+ using TestablePublishManager manager = CreateManager();
+ BaseObjectState notifier = MakeNotifier(manager, "ExtFactory");
+
+ var roots = new Dictionary { [notifier.BrowseName] = notifier };
+ var byId = new Dictionary { [notifier.NodeId] = notifier };
+
+ var builder = new NodeManagerBuilder(
+ manager.SystemContext,
+ nodeManager: Mock.Of(),
+ defaultNamespaceIndex: kNs,
+ rootResolver: q => roots.TryGetValue(q, out NodeState n) ? n : null,
+ nodeIdResolver: id => byId.TryGetValue(id, out NodeState n) ? n : null,
+ typeIdResolver: _ => []);
+ manager.AttachToBuilder(builder);
+
+ var captured = new TaskCompletionSource(
+ TaskCreationOptions.RunContinuationsAsynchronously);
+ notifier.OnReportEvent = (_, _, e) =>
+ {
+ if (e is BaseEventState evt)
+ {
+ captured.TrySetResult(evt);
+ }
+ };
+
+ Channel channel = Channel.CreateUnbounded();
+ builder.Node(notifier.BrowseName.Name)
+ .Publish(
+ (_, _, ct) => channel.Reader.ReadAllAsync(ct),
+ new EventPublishOptions { AlwaysOn = true });
+
+ await channel.Writer.WriteAsync(new BaseEventState(parent: null)).ConfigureAwait(false);
+
+ BaseEventState seen = await WaitForAsync(captured.Task).ConfigureAwait(false);
+ Assert.That(seen, Is.Not.Null);
+ }
+
+ [Test]
+ public void Publish_NullArgumentsOnExtension_Throw()
+ {
+ using TestablePublishManager manager = CreateManager();
+ BaseObjectState notifier = MakeNotifier(manager, "NullArgExt");
+
+ var roots = new Dictionary { [notifier.BrowseName] = notifier };
+ var byId = new Dictionary { [notifier.NodeId] = notifier };
+
+ var builder = new NodeManagerBuilder(
+ manager.SystemContext,
+ nodeManager: Mock.Of(),
+ defaultNamespaceIndex: kNs,
+ rootResolver: q => roots.TryGetValue(q, out NodeState n) ? n : null,
+ nodeIdResolver: id => byId.TryGetValue(id, out NodeState n) ? n : null,
+ typeIdResolver: _ => []);
+ manager.AttachToBuilder(builder);
+
+ INodeBuilder nodeBuilder = builder.Node(notifier.BrowseName.Name);
+
+ Assert.Throws(() =>
+ EventNotifierBuilderExtensions.Publish(
+ nodeBuilder: null,
+ factory: (_, _, ct) => EmptyStream(ct)));
+
+ Assert.Throws(() =>
+ nodeBuilder.Publish(
+ factory: null));
+
+ Assert.Throws(() =>
+ EventNotifierBuilderExtensions.Publish(
+ nodeBuilder: null,
+ source: AsyncEnumerable.Empty()));
+
+ Assert.Throws(() =>
+ nodeBuilder.Publish(
+ source: (IAsyncEnumerable)null));
+ }
+
+ [Test]
+ public void AttachToBuilder_NullBuilder_ThrowsArgumentNull()
+ {
+ using TestablePublishManager manager = CreateManager();
+ Assert.Throws(() => manager.AttachToBuilder(null));
+ }
+
+ #endregion
+
+ #region Helpers
+
+ private TestablePublishManager CreateManager()
+ {
+ var manager = new TestablePublishManager(
+ m_mockServer.Object,
+ m_configuration,
+ logger: null,
+ kNamespaceUri);
+ SetupMasterNodeManager(manager);
+ return manager;
+ }
+
+ private void SetupMasterNodeManager(TestablePublishManager manager)
+ {
+ m_mockMasterNodeManager
+ .Setup(m => m.GetManagerHandleAsync(It.IsAny(), It.IsAny()))
+ .Returns((nodeId, _) =>
+ {
+ NodeState nodeState = manager.Find(nodeId);
+ if (nodeState == null)
+ {
+ return new ValueTask<(object handle, IAsyncNodeManager nodeManager)>((null, null));
+ }
+ var handle = new NodeHandle(nodeId, nodeState);
+ return new ValueTask<(object handle, IAsyncNodeManager nodeManager)>((handle, manager));
+ });
+ }
+
+ private static BaseObjectState MakeNotifier(
+ TestablePublishManager manager,
+ string browseName,
+ byte eventNotifier = EventNotifiers.SubscribeToEvents)
+ {
+ var notifier = new BaseObjectState(parent: null)
+ {
+ NodeId = new NodeId(browseName, kNs),
+ BrowseName = new QualifiedName(browseName, kNs),
+ DisplayName = new LocalizedText(browseName),
+ EventNotifier = eventNotifier
+ };
+ manager.AddPublic(notifier);
+ return notifier;
+ }
+
+ private static async Task WaitForAsync(Task task)
+ {
+ Task completed = await Task.WhenAny(task, Task.Delay(s_signalTimeout)).ConfigureAwait(false);
+ if (completed != task)
+ {
+ Assert.Fail($"Operation did not signal within {s_signalTimeout.TotalSeconds:F1}s.");
+ }
+ await task.ConfigureAwait(false);
+ }
+
+ private static async Task WaitForAsync(Task task)
+ {
+ Task completed = await Task.WhenAny(task, Task.Delay(s_signalTimeout)).ConfigureAwait(false);
+ if (completed != task)
+ {
+ Assert.Fail($"Operation did not signal within {s_signalTimeout.TotalSeconds:F1}s.");
+ }
+ return await task.ConfigureAwait(false);
+ }
+
+ private static async IAsyncEnumerable CountingStream(
+ TaskCompletionSource started,
+ [EnumeratorCancellation] CancellationToken ct)
+ {
+ started.TrySetResult(true);
+ try
+ {
+ await Task.Delay(Timeout.Infinite, ct).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ }
+ yield break;
+ }
+
+ private static async IAsyncEnumerable CancelObservingStream(
+ TaskCompletionSource iteratorEntered,
+ TaskCompletionSource