From 2e2efb4ad9ea49b43b0bb237577c23e884099c1e Mon Sep 17 00:00:00 2001 From: Marc Schier Date: Tue, 12 May 2026 12:32:22 +0200 Subject: [PATCH 01/19] Add async read/write hooks to BaseVariableState and AsyncCustomNodeManager Introduces an end-to-end async path for variable Value-attribute reads and writes that does not require holding lock(this) while the user callback executes: * New AttributeReadResult, AttributeSimpleReadResult and AttributeWriteResult readonly record structs in NodeState carry the typed result of an async Value-attribute hook (no ref/out params across await). * Four new async delegates (NodeValueEventHandlerAsync, NodeValueSimpleEventHandlerAsync, NodeValueWriteEventHandlerAsync, NodeValueSimpleWriteEventHandlerAsync) sit alongside the existing sync hooks. * NodeState gains virtual ReadAttributeAsync / WriteAttributeAsync methods. The default implementation wraps the synchronous call in lock(this) so callers that have not opted into async hooks see bit-identical behaviour. * BaseVariableState exposes OnReadValueAsync, OnSimpleReadValueAsync, OnWriteValueAsync and OnSimpleWriteValueAsync slots and overrides the new virtuals to dispatch to them WITHOUT holding the lock during the await. On simple-read the framework still applies index range, data encoding and copy policy; on simple-write the cached value/status/timestamp are updated under lock after the hook completes. * AsyncCustomNodeManager.ReadAsync and WriteAsync now await the new ReadAttributeAsync / WriteAttributeAsync entry points instead of taking lock(source) around the synchronous call. Existing per-source locking semantics are preserved by the default async wrappers. * The fluent INodeBuilder surface gains four new async OnRead/OnWrite overloads that wire to the new BaseVariableState slots, with the same ThrowIfSlotOccupied + null-check pattern as the sync overloads. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Opc.Ua.Server/Fluent/INodeBuilder.cs | 25 ++ Libraries/Opc.Ua.Server/Fluent/NodeBuilder.cs | 36 ++ .../NodeManager/AsyncCustomNodeManager.cs | 37 +- Stack/Opc.Ua.Types/State/BaseVariableState.cs | 350 ++++++++++++++++++ Stack/Opc.Ua.Types/State/NodeState.cs | 172 +++++++++ 5 files changed, 602 insertions(+), 18 deletions(-) diff --git a/Libraries/Opc.Ua.Server/Fluent/INodeBuilder.cs b/Libraries/Opc.Ua.Server/Fluent/INodeBuilder.cs index a872e77f45..88d5a1bc04 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 . /// diff --git a/Libraries/Opc.Ua.Server/Fluent/NodeBuilder.cs b/Libraries/Opc.Ua.Server/Fluent/NodeBuilder.cs index f9b1da725c..1fd77897e6 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) { 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. /// From e557ecdd9cc2bd74211f97b3a80954b30e0f4c66 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Tue, 12 May 2026 17:06:25 +0200 Subject: [PATCH 02/19] Add typed IVariableBuilder fluent surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds typed sub-interfaces and impls so the existing fluent surface can expose simple Func / Action / Func> overloads for variables — without losing the lower-level Variant-based overloads on INodeBuilder. * IVariableBuilder + VariableBuilder derive from INodeBuilder / NodeBuilder (NodeBuilder unsealed). * Marshalling uses Variant.AsBoxedObject(BoxingBehavior.Legacy) for reads and Variant(object) for writes; the AOT-unsafe write path is scoped-suppressed with a TODO pointing at the planned per-type generated walker. * INodeManagerBuilder gains Variable(string), Variable(NodeId), VariableFromTypeId(NodeId), VariableFromTypeId(NodeId, QualifiedName). Server library builds clean across all 6 TFMs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Fluent/INodeManagerBuilder.cs | 38 ++++ .../Opc.Ua.Server/Fluent/IVariableBuilder.cs | 157 +++++++++++++ Libraries/Opc.Ua.Server/Fluent/NodeBuilder.cs | 2 +- .../Fluent/NodeManagerBuilder.cs | 62 +++++ .../Opc.Ua.Server/Fluent/VariableBuilder.cs | 214 ++++++++++++++++++ 5 files changed, 472 insertions(+), 1 deletion(-) create mode 100644 Libraries/Opc.Ua.Server/Fluent/IVariableBuilder.cs create mode 100644 Libraries/Opc.Ua.Server/Fluent/VariableBuilder.cs 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 1fd77897e6..910548a5e7 100644 --- a/Libraries/Opc.Ua.Server/Fluent/NodeBuilder.cs +++ b/Libraries/Opc.Ua.Server/Fluent/NodeBuilder.cs @@ -293,7 +293,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..35953502ab 100644 --- a/Libraries/Opc.Ua.Server/Fluent/NodeManagerBuilder.cs +++ b/Libraries/Opc.Ua.Server/Fluent/NodeManagerBuilder.cs @@ -237,6 +237,68 @@ 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)); + } + + private 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); + } + + 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 + } + } +} From 5e48d1a6d966e9969bac2caa7866eb4679423e34 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Tue, 12 May 2026 17:27:54 +0200 Subject: [PATCH 03/19] Add relative-child traversal API on INodeBuilder Adds INodeBuilder.Child(QualifiedName), Child(QualifiedName) and Variable(QualifiedName) so source-generated typed wrappers can walk one segment at a time without re-resolving from the manager root. Resolution uses NodeState.FindChild and reuses NodeManagerBuilder.ToVariableBuilder to keep the typed-variable marshalling story consistent. Adds 7 NUnit cases (Fluent category) covering the happy path, type-mismatch, null/missing browse-name, and non-variable rejection. All 66 fluent tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Opc.Ua.Server/Fluent/INodeBuilder.cs | 35 +++++++++ Libraries/Opc.Ua.Server/Fluent/NodeBuilder.cs | 51 +++++++++++++ .../Fluent/NodeManagerBuilder.cs | 2 +- .../Fluent/NodeManagerBuilderTests.cs | 73 +++++++++++++++++++ 4 files changed, 160 insertions(+), 1 deletion(-) diff --git a/Libraries/Opc.Ua.Server/Fluent/INodeBuilder.cs b/Libraries/Opc.Ua.Server/Fluent/INodeBuilder.cs index 88d5a1bc04..52fb2df029 100644 --- a/Libraries/Opc.Ua.Server/Fluent/INodeBuilder.cs +++ b/Libraries/Opc.Ua.Server/Fluent/INodeBuilder.cs @@ -171,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/NodeBuilder.cs b/Libraries/Opc.Ua.Server/Fluent/NodeBuilder.cs index 910548a5e7..42eb652bba 100644 --- a/Libraries/Opc.Ua.Server/Fluent/NodeBuilder.cs +++ b/Libraries/Opc.Ua.Server/Fluent/NodeBuilder.cs @@ -240,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) diff --git a/Libraries/Opc.Ua.Server/Fluent/NodeManagerBuilder.cs b/Libraries/Opc.Ua.Server/Fluent/NodeManagerBuilder.cs index 35953502ab..d2039d1f0a 100644 --- a/Libraries/Opc.Ua.Server/Fluent/NodeManagerBuilder.cs +++ b/Libraries/Opc.Ua.Server/Fluent/NodeManagerBuilder.cs @@ -278,7 +278,7 @@ public IVariableBuilder VariableFromTypeId(NodeId typeDefinition browseName)); } - private VariableBuilder ToVariableBuilder(NodeState node, string lookupHint) + internal VariableBuilder ToVariableBuilder(NodeState node, string lookupHint) { if (node is not BaseVariableState variable) { 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)); + } } } From 500718477a3681a26f427385be69b258a9e27856 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Tue, 12 May 2026 17:36:35 +0200 Subject: [PATCH 04/19] Mix old + new fluent APIs in MinimalBoilerServer demo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BoilerNodeManager.Configure now exercises five wiring styles: (1) browse-path with the legacy ref-Variant callback (2) absolute NodeId with the legacy ref-Variant callback (3) typed Variable(NodeId) + simple Func sync getter (4) typed Variable(NodeId) + Func> async getter — exercises the BaseVariableState.OnReadValueAsync slot end-to-end through AsyncCustomNodeManager.ReadAsync (5) NodeFromTypeId with OnNodeAdded lifecycle hook All five styles compose against the same INodeManagerBuilder, with the typed and async callbacks pulling in the new IVariableBuilder surface (committed at e557ecdd9) and the BaseVariableState async path (committed at 2e2efb4ad). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../BoilerNodeManager.Configure.cs | 60 +++++++++++++++++-- 1 file changed, 55 insertions(+), 5 deletions(-) diff --git a/Applications/MinimalBoilerServer/BoilerNodeManager.Configure.cs b/Applications/MinimalBoilerServer/BoilerNodeManager.Configure.cs index 42d1123211..2d9927c354 100644 --- a/Applications/MinimalBoilerServer/BoilerNodeManager.Configure.cs +++ b/Applications/MinimalBoilerServer/BoilerNodeManager.Configure.cs @@ -29,6 +29,7 @@ using System; using System.Threading; +using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Opc.Ua; using Opc.Ua.Server.Fluent; @@ -52,30 +53,61 @@ 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; 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)) @@ -120,5 +152,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)); + } } } From 719005aed705de065ac3b1dbb56924f077ce89f2 Mon Sep 17 00:00:00 2001 From: Marc Schier Date: Tue, 12 May 2026 17:55:23 +0200 Subject: [PATCH 05/19] Add async-hook tests for BaseVariableState 15 NUnit cases covering the new On{Read,Write}ValueAsync and On Simple{Read,Write}ValueAsync slots plus the ReadAttributeAsync/WriteAttributeAsync overrides on BaseVariableState: * full async slot routes Value reads/writes * lock(this) is released around the awaited handler * simple async slot reapplies cached StatusCode and respects index ranges (BadIndexRangeInvalid for non-null ranges) * exceptions and OperationCanceledException from the hook are caught and surfaced as BadUnexpectedError, mirroring the sync flow * CancellationToken propagates to the hook * cache (m_value, m_statusCode, m_timestamp) is updated on success and skipped on Bad return * fallback to base sync ReadAttribute/WriteAttribute when no async slot is set (preserves today's lock(this) semantics) * non-Value attributes never invoke the async hook * CurrentRead/CurrentWrite access checks short-circuit before the hook runs All 596 State tests pass on net10.0. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../State/BaseVariableStateAsyncHooksTests.cs | 499 ++++++++++++++++++ 1 file changed, 499 insertions(+) create mode 100644 Tests/Opc.Ua.Types.Tests/State/BaseVariableStateAsyncHooksTests.cs diff --git a/Tests/Opc.Ua.Types.Tests/State/BaseVariableStateAsyncHooksTests.cs b/Tests/Opc.Ua.Types.Tests/State/BaseVariableStateAsyncHooksTests.cs new file mode 100644 index 0000000000..2eead128c7 --- /dev/null +++ b/Tests/Opc.Ua.Types.Tests/State/BaseVariableStateAsyncHooksTests.cs @@ -0,0 +1,499 @@ +/* ======================================================================== + * 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; +using NUnit.Framework; +using Opc.Ua.Tests; + +namespace Opc.Ua.Types.Tests.State +{ + /// + /// Tests for the asynchronous Value-attribute hooks on + /// — the four On*ValueAsync + /// slots and the + /// / overrides + /// that route through them. + /// + /// + /// Coverage focuses on the contracts that distinguish the async + /// path from the synchronous fallback: + /// + /// The handler runs without holding lock(this). + /// propagates end-to-end. + /// Exceptions from the handler propagate to the caller. + /// The cached value / status / timestamp are updated on + /// successful writes (mirroring the sync flow). + /// When no async slot is set, the override defers to the + /// synchronous flow under the existing lock(this). + /// + /// + [TestFixture] + [Category("NodeState")] + [SetCulture("en-us")] + [SetUICulture("en-us")] + [Parallelizable] + public class BaseVariableStateAsyncHooksTests + { + private ITelemetryContext m_telemetry; + private ServiceMessageContext m_messageContext; + + [OneTimeSetUp] + protected void OneTimeSetUp() + { + m_telemetry = NUnitTelemetryContext.Create(); + m_messageContext = ServiceMessageContext.CreateEmpty(m_telemetry); + } + + [OneTimeTearDown] + protected void OneTimeTearDown() + { + (m_messageContext as IDisposable)?.Dispose(); + } + + private SystemContext CreateSystemContext() + { + return new SystemContext(m_telemetry) + { + NamespaceUris = m_messageContext.NamespaceUris, + TypeTable = new TypeTable(m_messageContext.NamespaceUris) + }; + } + + private static BaseDataVariableState CreateReadableVariable() + { + return new BaseDataVariableState(null) + { + NodeId = new NodeId("Var", 0), + BrowseName = new QualifiedName("Var", 0), + DisplayName = new LocalizedText("Var"), + DataType = DataTypeIds.Double, + ValueRank = ValueRanks.Scalar, + AccessLevel = AccessLevels.CurrentReadOrWrite, + UserAccessLevel = AccessLevels.CurrentReadOrWrite + }; + } + + // ----------------------------------------------------------------- + // OnReadValueAsync (full) + // ----------------------------------------------------------------- + + [Test] + public async Task ReadAttributeAsyncRoutesValueReadsToFullAsyncSlot() + { + SystemContext ctx = CreateSystemContext(); + BaseDataVariableState v = CreateReadableVariable(); + DateTimeUtc handlerTimestamp = DateTimeUtc.Now; + + v.OnReadValueAsync = (c, n, range, encoding, ct) => + { + Assert.That(c, Is.SameAs(ctx)); + Assert.That(n, Is.SameAs(v)); + return new ValueTask( + new AttributeReadResult( + ServiceResult.Good, + new Variant(42.5), + StatusCodes.Good, + handlerTimestamp)); + }; + + var dv = new DataValue(); + ServiceResult result = await v.ReadAttributeAsync( + ctx, Attributes.Value, NumericRange.Null, QualifiedName.Null, dv).ConfigureAwait(false); + + Assert.That(ServiceResult.IsGood(result), Is.True); + Assert.That(dv.WrappedValue.GetDouble(), Is.EqualTo(42.5)); + Assert.That(dv.SourceTimestamp, Is.EqualTo((DateTime)handlerTimestamp)); + } + + [Test] + public async Task ReadAttributeAsyncFullSlotReleasesNodeStateLockDuringAwait() + { + SystemContext ctx = CreateSystemContext(); + BaseDataVariableState v = CreateReadableVariable(); + using var inHandlerGate = new ManualResetEventSlim(false); + using var releaseGate = new ManualResetEventSlim(false); + + v.OnReadValueAsync = async (c, n, range, encoding, ct) => + { + inHandlerGate.Set(); + // Block on a worker thread that takes lock(this) to prove + // we are NOT holding it across the await. + releaseGate.Wait(TimeSpan.FromSeconds(5), ct); + await Task.Yield(); + return new AttributeReadResult( + ServiceResult.Good, new Variant(7.0), StatusCodes.Good, DateTimeUtc.Now); + }; + + var dv = new DataValue(); + ValueTask readTask = v.ReadAttributeAsync( + ctx, Attributes.Value, NumericRange.Null, QualifiedName.Null, dv); + + Assert.That(inHandlerGate.Wait(TimeSpan.FromSeconds(5)), Is.True, + "handler did not start"); + + // Acquire lock(this) on a worker thread - this only succeeds + // if the read path released the lock before awaiting the + // handler. + bool lockAcquired = false; + Task lockTask = Task.Run(() => + { +#pragma warning disable CA2002 + lock (v) +#pragma warning restore CA2002 + { + lockAcquired = true; + } + }); + + Assert.That(lockTask.Wait(TimeSpan.FromSeconds(5)), Is.True, + "lock(v) was not released during the async handler"); + Assert.That(lockAcquired, Is.True); + + releaseGate.Set(); + ServiceResult result = await readTask.ConfigureAwait(false); + Assert.That(ServiceResult.IsGood(result), Is.True); + } + + // ----------------------------------------------------------------- + // OnSimpleReadValueAsync + // ----------------------------------------------------------------- + + [Test] + public async Task ReadAttributeAsyncRoutesValueReadsToSimpleAsyncSlot() + { + SystemContext ctx = CreateSystemContext(); + BaseDataVariableState v = CreateReadableVariable(); + // Simple async path reuses the variable's cached StatusCode; + // BaseVariableState defaults that to BadWaitingForInitialData, + // so promote it to Good to validate the success path. + v.StatusCode = StatusCodes.Good; + + v.OnSimpleReadValueAsync = (c, n, ct) => new ValueTask( + new AttributeSimpleReadResult(ServiceResult.Good, new Variant(123.5))); + + var dv = new DataValue(); + ServiceResult result = await v.ReadAttributeAsync( + ctx, Attributes.Value, NumericRange.Null, QualifiedName.Null, dv).ConfigureAwait(false); + + Assert.That(ServiceResult.IsGood(result), Is.True); + Assert.That(dv.WrappedValue.GetDouble(), Is.EqualTo(123.5)); + } + + [Test] + public async Task ReadAttributeAsyncSimpleSlotPropagatesCachedStatusCode() + { + SystemContext ctx = CreateSystemContext(); + BaseDataVariableState v = CreateReadableVariable(); + // The simple async path mirrors OnSimpleReadValue and reuses the + // variable's cached StatusCode — verify the framework + // propagates a non-Good cached code to the caller. + Assert.That(v.StatusCode.Code, Is.EqualTo((uint)StatusCodes.BadWaitingForInitialData)); + + v.OnSimpleReadValueAsync = (c, n, ct) => new ValueTask( + new AttributeSimpleReadResult(ServiceResult.Good, new Variant(123.5))); + + var dv = new DataValue(); + ServiceResult result = await v.ReadAttributeAsync( + ctx, Attributes.Value, NumericRange.Null, QualifiedName.Null, dv).ConfigureAwait(false); + + Assert.That(result.StatusCode.Code, Is.EqualTo((uint)StatusCodes.BadWaitingForInitialData)); + } + + // ----------------------------------------------------------------- + // Cancellation + // ----------------------------------------------------------------- + + [Test] + public async Task ReadAttributeAsyncWrapsCancellationFromFullSlot() + { + SystemContext ctx = CreateSystemContext(); + BaseDataVariableState v = CreateReadableVariable(); + using var cts = new CancellationTokenSource(); + CancellationToken seenToken = default; + + v.OnReadValueAsync = (c, n, range, encoding, ct) => + { + seenToken = ct; + ct.ThrowIfCancellationRequested(); + return new ValueTask( + new AttributeReadResult( + ServiceResult.Good, new Variant(0.0), StatusCodes.Good, DateTimeUtc.Now)); + }; + + cts.Cancel(); + var dv = new DataValue(); + // The async hook contract mirrors the sync flow: exceptions + // (including OperationCanceledException) are caught and surfaced + // as a Bad ServiceResult, never thrown to the caller. + ServiceResult result = await v.ReadAttributeAsync( + ctx, Attributes.Value, NumericRange.Null, QualifiedName.Null, dv, cts.Token).ConfigureAwait(false); + + Assert.That(seenToken, Is.EqualTo(cts.Token), "Cancellation token must propagate to the hook."); + Assert.That(result.StatusCode.Code, Is.EqualTo((uint)StatusCodes.BadUnexpectedError)); + Assert.That(result.InnerResult, Is.Not.Null); + } + + [Test] + public async Task ReadAttributeAsyncWrapsHandlerExceptions() + { + SystemContext ctx = CreateSystemContext(); + BaseDataVariableState v = CreateReadableVariable(); + + v.OnReadValueAsync = (c, n, range, encoding, ct) => throw new InvalidOperationException("boom"); + + var dv = new DataValue(); + ServiceResult result = await v.ReadAttributeAsync( + ctx, Attributes.Value, NumericRange.Null, QualifiedName.Null, dv).ConfigureAwait(false); + + Assert.That(result.StatusCode.Code, Is.EqualTo((uint)StatusCodes.BadUnexpectedError)); + Assert.That(dv.StatusCode.Code, Is.EqualTo((uint)StatusCodes.BadUnexpectedError)); + } + + // ----------------------------------------------------------------- + // OnWriteValueAsync (full) - cache update on success + // ----------------------------------------------------------------- + + [Test] + public async Task WriteAttributeAsyncRoutesValueWritesToFullAsyncSlot() + { + SystemContext ctx = CreateSystemContext(); + BaseDataVariableState v = CreateReadableVariable(); + double observedValue = double.NaN; + + v.OnWriteValueAsync = (c, n, range, value, ct) => + { + observedValue = value.GetDouble(); + return new ValueTask( + new AttributeWriteResult(ServiceResult.Good)); + }; + + var dv = new DataValue + { + WrappedValue = new Variant(99.5), + StatusCode = StatusCodes.Good, + SourceTimestamp = DateTimeUtc.Now + }; + + ServiceResult result = await v.WriteAttributeAsync( + ctx, Attributes.Value, NumericRange.Null, dv).ConfigureAwait(false); + + Assert.That(ServiceResult.IsGood(result), Is.True); + Assert.That(observedValue, Is.EqualTo(99.5)); + Assert.That(v.Value.GetDouble(), Is.EqualTo(99.5), + "BaseVariableState should mirror the value into its cache after a successful async write."); + } + + [Test] + public async Task WriteAttributeAsyncSkipsCacheOnHandlerFailure() + { + SystemContext ctx = CreateSystemContext(); + BaseDataVariableState v = CreateReadableVariable(); + v.Value = 1.0; + + v.OnWriteValueAsync = (c, n, range, value, ct) => + new ValueTask( + new AttributeWriteResult(StatusCodes.BadInvalidArgument)); + + var dv = new DataValue + { + WrappedValue = new Variant(99.5), + StatusCode = StatusCodes.Good, + SourceTimestamp = DateTimeUtc.Now + }; + + ServiceResult result = await v.WriteAttributeAsync( + ctx, Attributes.Value, NumericRange.Null, dv).ConfigureAwait(false); + + Assert.That(result.StatusCode.Code, Is.EqualTo((uint)StatusCodes.BadInvalidArgument)); + Assert.That(v.Value.GetDouble(), Is.EqualTo(1.0), + "Cache must NOT advance when the async hook reports a Bad status."); + } + + // ----------------------------------------------------------------- + // OnSimpleWriteValueAsync + // ----------------------------------------------------------------- + + [Test] + public async Task WriteAttributeAsyncRoutesValueWritesToSimpleAsyncSlot() + { + SystemContext ctx = CreateSystemContext(); + BaseDataVariableState v = CreateReadableVariable(); + + v.OnSimpleWriteValueAsync = (c, n, value, ct) => new ValueTask( + new AttributeWriteResult(ServiceResult.Good)); + + var dv = new DataValue + { + WrappedValue = new Variant(11.0), + StatusCode = StatusCodes.Good, + SourceTimestamp = DateTimeUtc.Now + }; + + ServiceResult result = await v.WriteAttributeAsync( + ctx, Attributes.Value, NumericRange.Null, dv).ConfigureAwait(false); + + Assert.That(ServiceResult.IsGood(result), Is.True); + Assert.That(v.Value.GetDouble(), Is.EqualTo(11.0)); + } + + [Test] + public async Task WriteAttributeAsyncRejectsIndexRangeOnSimpleSlot() + { + SystemContext ctx = CreateSystemContext(); + BaseDataVariableState v = CreateReadableVariable(); + v.OnSimpleWriteValueAsync = (c, n, value, ct) => new ValueTask( + new AttributeWriteResult(ServiceResult.Good)); + + var range = NumericRange.Parse("0:3"); + var dv = new DataValue + { + WrappedValue = new Variant(11.0), + StatusCode = StatusCodes.Good, + SourceTimestamp = DateTimeUtc.Now + }; + + ServiceResult result = await v.WriteAttributeAsync( + ctx, Attributes.Value, range, dv).ConfigureAwait(false); + + Assert.That(result.StatusCode.Code, Is.EqualTo((uint)StatusCodes.BadIndexRangeInvalid), + "The simple async write hook does not support index-range writes."); + } + + // ----------------------------------------------------------------- + // Fallback to sync path when no async slot is set + // ----------------------------------------------------------------- + + [Test] + public async Task ReadAttributeAsyncFallsBackToSyncWhenNoAsyncSlotSet() + { + SystemContext ctx = CreateSystemContext(); + BaseDataVariableState v = CreateReadableVariable(); + v.Value = 5.5; + + var dv = new DataValue(); + ServiceResult result = await v.ReadAttributeAsync( + ctx, Attributes.Value, NumericRange.Null, QualifiedName.Null, dv).ConfigureAwait(false); + + Assert.That(ServiceResult.IsGood(result), Is.True); + Assert.That(dv.WrappedValue.GetDouble(), Is.EqualTo(5.5)); + } + + [Test] + public async Task WriteAttributeAsyncFallsBackToSyncWhenNoAsyncSlotSet() + { + SystemContext ctx = CreateSystemContext(); + BaseDataVariableState v = CreateReadableVariable(); + v.Value = 1.0; + + var dv = new DataValue + { + WrappedValue = new Variant(2.0), + StatusCode = StatusCodes.Good, + SourceTimestamp = DateTimeUtc.Now + }; + + ServiceResult result = await v.WriteAttributeAsync( + ctx, Attributes.Value, NumericRange.Null, dv).ConfigureAwait(false); + + Assert.That(ServiceResult.IsGood(result), Is.True); + Assert.That(v.Value.GetDouble(), Is.EqualTo(2.0)); + } + + [Test] + public async Task ReadAttributeAsyncFallsThroughForNonValueAttribute() + { + SystemContext ctx = CreateSystemContext(); + BaseDataVariableState v = CreateReadableVariable(); + v.OnReadValueAsync = (c, n, range, encoding, ct) => + { + Assert.Fail("OnReadValueAsync must not run for non-Value attributes."); + return new ValueTask( + new AttributeReadResult( + ServiceResult.Good, Variant.Null, StatusCodes.Good, DateTimeUtc.Now)); + }; + + var dv = new DataValue(); + ServiceResult result = await v.ReadAttributeAsync( + ctx, Attributes.DisplayName, NumericRange.Null, QualifiedName.Null, dv).ConfigureAwait(false); + + Assert.That(ServiceResult.IsGood(result), Is.True); + Assert.That(dv.WrappedValue.GetLocalizedText().Text, Is.EqualTo("Var")); + } + + // ----------------------------------------------------------------- + // Access control + // ----------------------------------------------------------------- + + [Test] + public async Task ReadAttributeAsyncReturnsBadNotReadableForNoAccessLevel() + { + SystemContext ctx = CreateSystemContext(); + BaseDataVariableState v = CreateReadableVariable(); + v.AccessLevel = AccessLevels.None; + v.OnReadValueAsync = (c, n, range, encoding, ct) => + { + Assert.Fail("Async hook must not run when CurrentRead is denied."); + return new ValueTask(default(AttributeReadResult)); + }; + + var dv = new DataValue(); + ServiceResult result = await v.ReadAttributeAsync( + ctx, Attributes.Value, NumericRange.Null, QualifiedName.Null, dv).ConfigureAwait(false); + + Assert.That(result.StatusCode.Code, Is.EqualTo((uint)StatusCodes.BadNotReadable)); + } + + [Test] + public async Task WriteAttributeAsyncReturnsBadNotWritableForNoAccessLevel() + { + SystemContext ctx = CreateSystemContext(); + BaseDataVariableState v = CreateReadableVariable(); + v.AccessLevel = AccessLevels.CurrentRead; + v.OnWriteValueAsync = (c, n, range, value, ct) => + { + Assert.Fail("Async hook must not run when CurrentWrite is denied."); + return new ValueTask(default(AttributeWriteResult)); + }; + + var dv = new DataValue + { + WrappedValue = new Variant(2.0), + StatusCode = StatusCodes.Good, + SourceTimestamp = DateTimeUtc.Now + }; + + ServiceResult result = await v.WriteAttributeAsync( + ctx, Attributes.Value, NumericRange.Null, dv).ConfigureAwait(false); + + Assert.That(result.StatusCode.Code, Is.EqualTo((uint)StatusCodes.BadNotWritable)); + } + } +} From 0cf311c3ae6ef91780e6b62117f183fb9991bd4f Mon Sep 17 00:00:00 2001 From: Marc Schier Date: Tue, 12 May 2026 18:09:54 +0200 Subject: [PATCH 06/19] Add round-trip tests for typed IVariableBuilder Covers the typed Func/Action/Func<...,ValueTask> overloads on IVariableBuilder: each registers the appropriate hook slot on the underlying BaseDataVariableState, and driving the variable through ReadAttribute/WriteAttribute (sync) and ReadAttributeAsync/WriteAttributeAsync (async) reproduces the typed values the user supplied. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Fluent/TypedBuilderTests.cs | 480 ++++++++++++++++++ 1 file changed, 480 insertions(+) create mode 100644 Tests/Opc.Ua.Server.Tests/Fluent/TypedBuilderTests.cs diff --git a/Tests/Opc.Ua.Server.Tests/Fluent/TypedBuilderTests.cs b/Tests/Opc.Ua.Server.Tests/Fluent/TypedBuilderTests.cs new file mode 100644 index 0000000000..18e6bbc1fa --- /dev/null +++ b/Tests/Opc.Ua.Server.Tests/Fluent/TypedBuilderTests.cs @@ -0,0 +1,480 @@ +/* ======================================================================== + * 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; +using System.Threading.Tasks; +using Moq; +using NUnit.Framework; +using Opc.Ua.Server.Fluent; +using Opc.Ua.Tests; + +// CA2000: BaseObjectState/BaseDataVariableState instances created in the test fixture +// are passed to the builder under test which owns them for the test fixture lifetime. +#pragma warning disable CA2000 + +namespace Opc.Ua.Server.Tests.Fluent +{ + /// + /// End-to-end round-trip tests for : + /// each typed convenience overload registers the appropriate hook + /// slot on the underlying , and + /// driving the variable through ReadAttribute / + /// WriteAttribute (sync) and ReadAttributeAsync / + /// WriteAttributeAsync (async) reproduces the typed values + /// the user supplied. + /// + [TestFixture] + [Category("Fluent")] + public class TypedBuilderTests + { + private const ushort kNs = 2; + private static readonly NamespaceTable s_namespaces = new(); + private static readonly TypeTable s_typeTable = new(s_namespaces); + + private static SystemContext CreateContext() + { + return new SystemContext(NUnitTelemetryContext.Create()) + { + NamespaceUris = s_namespaces, + TypeTable = s_typeTable + }; + } + + private static (NodeManagerBuilder Builder, BaseDataVariableState Var) + CreateBuilderForVariable(NodeId dataType) + { + SystemContext ctx = CreateContext(); + + var root = new BaseObjectState(parent: null) + { + NodeId = new NodeId("Root", kNs), + BrowseName = new QualifiedName("Root", kNs), + DisplayName = new LocalizedText("Root") + }; + + var v = new BaseDataVariableState(root) + { + NodeId = new NodeId("Root.Var", kNs), + BrowseName = new QualifiedName("Var", kNs), + DisplayName = new LocalizedText("Var"), + DataType = dataType, + ValueRank = ValueRanks.Scalar, + AccessLevel = AccessLevels.CurrentReadOrWrite, + UserAccessLevel = AccessLevels.CurrentReadOrWrite, + StatusCode = StatusCodes.Good + }; + root.AddChild(v); + + var roots = new Dictionary { [root.BrowseName] = root }; + var byId = new Dictionary + { + [root.NodeId] = root, + [v.NodeId] = v + }; + + var builder = new NodeManagerBuilder( + ctx, + 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: _ => []); + + return (builder, v); + } + + // ----------------------------------------------------------------- + // Resolution + // ----------------------------------------------------------------- + + [Test] + public void VariableByPathReturnsTypedBuilder() + { + (NodeManagerBuilder b, BaseDataVariableState v) = + CreateBuilderForVariable(DataTypeIds.Int32); + + IVariableBuilder typed = b.Variable("Root/Var"); + + Assert.That(typed, Is.Not.Null); + Assert.That(typed.Node, Is.SameAs(v)); + } + + [Test] + public void VariableByNodeIdReturnsTypedBuilder() + { + (NodeManagerBuilder b, BaseDataVariableState v) = + CreateBuilderForVariable(DataTypeIds.Int32); + + IVariableBuilder typed = b.Variable(new NodeId("Root.Var", kNs)); + + Assert.That(typed, Is.Not.Null); + Assert.That(typed.Node, Is.SameAs(v)); + } + + [Test] + public void AsVariableReturnsTypedBuilder() + { + (NodeManagerBuilder b, BaseDataVariableState v) = + CreateBuilderForVariable(DataTypeIds.Int32); + + INodeBuilder nb = b.Node("Root/Var"); + IVariableBuilder typed = nb.AsVariable(); + + Assert.That(typed.Node, Is.SameAs(v)); + } + + [Test] + public void AsVariableThrowsWhenNodeIsNotAVariable() + { + SystemContext ctx = CreateContext(); + var folder = new BaseObjectState(parent: null) + { + NodeId = new NodeId("Root", kNs), + BrowseName = new QualifiedName("Root", kNs), + DisplayName = new LocalizedText("Root") + }; + var roots = new Dictionary { [folder.BrowseName] = folder }; + var byId = new Dictionary { [folder.NodeId] = folder }; + var builder = new NodeManagerBuilder( + ctx, + 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: _ => []); + + INodeBuilder nb = builder.Node("Root"); + + ServiceResultException ex = Assert.Throws( + () => nb.AsVariable()); + Assert.That(ex.StatusCode, Is.EqualTo((uint)StatusCodes.BadInvalidArgument)); + } + + // ----------------------------------------------------------------- + // OnRead — sync + // ----------------------------------------------------------------- + + [Test] + public void OnReadFuncTValueIsInvokedOnValueRead() + { + (NodeManagerBuilder b, BaseDataVariableState v) = + CreateBuilderForVariable(DataTypeIds.Int32); + + int callCount = 0; + b.Variable("Root/Var").OnRead(() => + { + callCount++; + return 42; + }); + + var dv = new DataValue(); + ServiceResult result = v.ReadAttribute( + CreateContext(), Attributes.Value, NumericRange.Null, QualifiedName.Null, dv); + + Assert.That(ServiceResult.IsGood(result), Is.True); + Assert.That(dv.WrappedValue.GetInt32(), Is.EqualTo(42)); + Assert.That(callCount, Is.EqualTo(1)); + } + + [Test] + public void OnReadFuncContextTValueReceivesSystemContext() + { + (NodeManagerBuilder b, BaseDataVariableState v) = + CreateBuilderForVariable(DataTypeIds.Int32); + + ISystemContext seenContext = null; + b.Variable("Root/Var").OnRead((ISystemContext c) => + { + seenContext = c; + return 7; + }); + + SystemContext ctx = CreateContext(); + var dv = new DataValue(); + v.ReadAttribute(ctx, Attributes.Value, NumericRange.Null, QualifiedName.Null, dv); + + Assert.That(seenContext, Is.SameAs(ctx)); + Assert.That(dv.WrappedValue.GetInt32(), Is.EqualTo(7)); + } + + [Test] + public void OnReadThrowsForNullGetter() + { + (NodeManagerBuilder b, _) = + CreateBuilderForVariable(DataTypeIds.Int32); + + IVariableBuilder tb = b.Variable("Root/Var"); + + Assert.Throws(() => tb.OnRead((Func)null)); + Assert.Throws(() => tb.OnRead((Func)null)); + } + + // ----------------------------------------------------------------- + // OnRead — async + // ----------------------------------------------------------------- + + [Test] + public async Task OnReadAsyncFuncTValueRoutesThroughAsyncSlot() + { + (NodeManagerBuilder b, BaseDataVariableState v) = + CreateBuilderForVariable(DataTypeIds.Int32); + + int callCount = 0; + b.Variable("Root/Var").OnRead(async (CancellationToken ct) => + { + callCount++; + await Task.Yield(); + return 99; + }); + + // Async-typed OnRead must register OnSimpleReadValueAsync, not the sync slot. + Assert.That(v.OnSimpleReadValueAsync, Is.Not.Null); + Assert.That(v.OnSimpleReadValue, Is.Null); + + var dv = new DataValue(); + ServiceResult result = await v.ReadAttributeAsync( + CreateContext(), Attributes.Value, NumericRange.Null, QualifiedName.Null, dv).ConfigureAwait(false); + + Assert.That(ServiceResult.IsGood(result), Is.True); + Assert.That(dv.WrappedValue.GetInt32(), Is.EqualTo(99)); + Assert.That(callCount, Is.EqualTo(1)); + } + + [Test] + public async Task OnReadAsyncReceivesCancellationToken() + { + (NodeManagerBuilder b, BaseDataVariableState v) = + CreateBuilderForVariable(DataTypeIds.Int32); + + using var cts = new CancellationTokenSource(); + CancellationToken seenToken = default; + + b.Variable("Root/Var").OnRead((CancellationToken ct) => + { + seenToken = ct; + return new ValueTask(33); + }); + + var dv = new DataValue(); + ServiceResult result = await v.ReadAttributeAsync( + CreateContext(), + Attributes.Value, + NumericRange.Null, + QualifiedName.Null, + dv, + cts.Token).ConfigureAwait(false); + + Assert.That(ServiceResult.IsGood(result), Is.True); + Assert.That(seenToken, Is.EqualTo(cts.Token)); + } + + // ----------------------------------------------------------------- + // OnWrite — sync + // ----------------------------------------------------------------- + + [Test] + public void OnWriteActionTValueIsInvokedOnValueWrite() + { + (NodeManagerBuilder b, BaseDataVariableState v) = + CreateBuilderForVariable(DataTypeIds.Double); + + double captured = double.NaN; + b.Variable("Root/Var").OnWrite((double x) => captured = x); + + // Action overload registers OnSimpleWriteValue (since it + // only needs the Variant, not the full ref-StatusCode/Timestamp + // payload). Confirm wiring before we verify behavior. + Assert.That(v.OnSimpleWriteValue, Is.Not.Null); + + var dv = new DataValue + { + WrappedValue = new Variant(2.5), + StatusCode = StatusCodes.Good, + SourceTimestamp = DateTimeUtc.Now + }; + ServiceResult result = v.WriteAttribute( + CreateContext(), Attributes.Value, NumericRange.Null, dv); + + Assert.That(ServiceResult.IsGood(result), Is.True, + $"StatusCode=0x{result.StatusCode.Code:X8}; Inner={result.InnerResult}"); + Assert.That(captured, Is.EqualTo(2.5)); + } + + [Test] + public void OnWriteActionContextTValueReceivesSystemContext() + { + (NodeManagerBuilder b, BaseDataVariableState v) = + CreateBuilderForVariable(DataTypeIds.Double); + + ISystemContext seenContext = null; + double captured = 0; + b.Variable("Root/Var").OnWrite((ISystemContext c, double x) => + { + seenContext = c; + captured = x; + }); + + SystemContext ctx = CreateContext(); + var dv = new DataValue + { + WrappedValue = new Variant(11.0), + StatusCode = StatusCodes.Good, + SourceTimestamp = DateTimeUtc.Now + }; + v.WriteAttribute(ctx, Attributes.Value, NumericRange.Null, dv); + + Assert.That(seenContext, Is.SameAs(ctx)); + Assert.That(captured, Is.EqualTo(11.0)); + } + + [Test] + public void OnWriteThrowsForNullSetter() + { + (NodeManagerBuilder b, _) = + CreateBuilderForVariable(DataTypeIds.Double); + + IVariableBuilder tb = b.Variable("Root/Var"); + + Assert.Throws(() => tb.OnWrite((Action)null)); + Assert.Throws( + () => tb.OnWrite((Action)null)); + } + + // ----------------------------------------------------------------- + // OnWrite — async + // ----------------------------------------------------------------- + + [Test] + public async Task OnWriteAsyncRoutesThroughAsyncSlot() + { + (NodeManagerBuilder b, BaseDataVariableState v) = + CreateBuilderForVariable(DataTypeIds.Double); + + double captured = 0; + b.Variable("Root/Var").OnWrite(async (double x, CancellationToken ct) => + { + captured = x; + await Task.Yield(); + }); + + // Async-typed OnWrite must register OnSimpleWriteValueAsync, not the sync slot. + Assert.That(v.OnSimpleWriteValueAsync, Is.Not.Null); + Assert.That(v.OnSimpleWriteValue, Is.Null); + + var dv = new DataValue + { + WrappedValue = new Variant(7.5), + StatusCode = StatusCodes.Good, + SourceTimestamp = DateTimeUtc.Now + }; + ServiceResult result = await v.WriteAttributeAsync( + CreateContext(), Attributes.Value, NumericRange.Null, dv).ConfigureAwait(false); + + Assert.That(ServiceResult.IsGood(result), Is.True); + Assert.That(captured, Is.EqualTo(7.5)); + } + + [Test] + public async Task OnWriteAsyncReceivesContextAndCancellationToken() + { + (NodeManagerBuilder b, BaseDataVariableState v) = + CreateBuilderForVariable(DataTypeIds.Double); + + using var cts = new CancellationTokenSource(); + ISystemContext seenContext = null; + CancellationToken seenToken = default; + + b.Variable("Root/Var").OnWrite( + (ISystemContext c, double x, CancellationToken ct) => + { + seenContext = c; + seenToken = ct; + return ValueTask.CompletedTask; + }); + + SystemContext ctx = CreateContext(); + var dv = new DataValue + { + WrappedValue = new Variant(1.0), + StatusCode = StatusCodes.Good, + SourceTimestamp = DateTimeUtc.Now + }; + await v.WriteAttributeAsync( + ctx, Attributes.Value, NumericRange.Null, dv, cts.Token).ConfigureAwait(false); + + Assert.That(seenContext, Is.SameAs(ctx)); + Assert.That(seenToken, Is.EqualTo(cts.Token)); + } + + // ----------------------------------------------------------------- + // Type-marshalling + // ----------------------------------------------------------------- + + [Test] + public void OnReadHandlesNullFromVariantForReferenceTypes() + { + (NodeManagerBuilder b, BaseDataVariableState v) = + CreateBuilderForVariable(DataTypeIds.String); + + // Hook a writer that captures whatever the framework hands us; + // we want to verify ReadValue can yield a default(string) if + // OnRead returns null. + b.Variable("Root/Var").OnRead(() => null); + + var dv = new DataValue(); + ServiceResult result = v.ReadAttribute( + CreateContext(), Attributes.Value, NumericRange.Null, QualifiedName.Null, dv); + + Assert.That(ServiceResult.IsGood(result), Is.True); + Assert.That(dv.WrappedValue.IsNull, Is.True); + } + + [Test] + public void OnWriteFromVariantUnwrapsTypedValue() + { + (NodeManagerBuilder b, BaseDataVariableState v) = + CreateBuilderForVariable(DataTypeIds.String); + + string captured = null; + b.Variable("Root/Var").OnWrite((string s) => captured = s); + + var dv = new DataValue + { + WrappedValue = new Variant("hello"), + StatusCode = StatusCodes.Good, + SourceTimestamp = DateTimeUtc.Now + }; + v.WriteAttribute(CreateContext(), Attributes.Value, NumericRange.Null, dv); + + Assert.That(captured, Is.EqualTo("hello")); + } + } +} From d40e80520613eb426661686834b58260a630aa76 Mon Sep 17 00:00:00 2001 From: Marc Schier Date: Tue, 12 May 2026 19:33:12 +0200 Subject: [PATCH 07/19] Add typed model-traversal source generator Adds FluentBuilderGenerator that walks the design's predefined-instance tree and emits a typed sibling for every NodeManagerBuilder. Each generated wrapper exposes IntelliSense-friendly accessors for child instances, variables (IVariableBuilder) and methods (sync/async OnCall) so wiring sites become builder.Boilers.Boiler__1.LCX001.Measurement.OnRead(...) instead of a stringly typed browse path. The NodeManager template now also emits a Configure(I{Manager}NodeManagerBuilder) partial alongside the existing Configure(INodeManagerBuilder); both partials run, both are optional, and the typed surface is fully AOT-safe (no dynamic, no MakeGenericType, no reflection). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../BoilerNodeManager.Configure.cs | 52 + .../Generators.cs | 6 + .../Generators/FluentBuilderGenerator.cs | 1318 +++++++++++++++++ .../Generators/FluentBuilderTemplates.cs | 66 + .../Generators/NodeManagerTemplates.cs | 9 + 5 files changed, 1451 insertions(+) create mode 100644 Tools/Opc.Ua.SourceGeneration.Core/Generators/FluentBuilderGenerator.cs create mode 100644 Tools/Opc.Ua.SourceGeneration.Core/Generators/FluentBuilderTemplates.cs diff --git a/Applications/MinimalBoilerServer/BoilerNodeManager.Configure.cs b/Applications/MinimalBoilerServer/BoilerNodeManager.Configure.cs index 2d9927c354..e2faaad170 100644 --- a/Applications/MinimalBoilerServer/BoilerNodeManager.Configure.cs +++ b/Applications/MinimalBoilerServer/BoilerNodeManager.Configure.cs @@ -118,6 +118,58 @@ 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); + } + + 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."); + } + private ServiceResult GenerateDrumLevel( ISystemContext context, NodeState node, diff --git a/Tools/Opc.Ua.SourceGeneration.Core/Generators.cs b/Tools/Opc.Ua.SourceGeneration.Core/Generators.cs index ffc47416a9..62b051662f 100644 --- a/Tools/Opc.Ua.SourceGeneration.Core/Generators.cs +++ b/Tools/Opc.Ua.SourceGeneration.Core/Generators.cs @@ -526,6 +526,12 @@ private static void Generate( OverrideClassName = designOptions.NodeManagerClassName, EmitFactory = designOptions.EmitNodeManagerFactory }.Emit(); + + new FluentBuilderGenerator(context) + { + OverrideManagerNamespace = designOptions.NodeManagerNamespace, + OverrideManagerClassName = designOptions.NodeManagerClassName + }.Emit(); } if (context.Options?.OmitObjectTypeProxies != true) diff --git a/Tools/Opc.Ua.SourceGeneration.Core/Generators/FluentBuilderGenerator.cs b/Tools/Opc.Ua.SourceGeneration.Core/Generators/FluentBuilderGenerator.cs new file mode 100644 index 0000000000..f846d9fe13 --- /dev/null +++ b/Tools/Opc.Ua.SourceGeneration.Core/Generators/FluentBuilderGenerator.cs @@ -0,0 +1,1318 @@ +/* ======================================================================== + * 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.IO; +using System.Linq; +using System.Xml; +using Opc.Ua.Schema.Model; + +namespace Opc.Ua.SourceGeneration +{ + /// + /// Emits a typed, intellisense-aware fluent builder facade for the + /// model's predefined-instance tree. Drops in alongside the existing + /// output so users can write + /// b.Boilers.Boiler__1.DrumX001.LIX001.Output.OnRead(...) + /// inside their Configure partial. + /// + /// + /// + /// One typed wrapper class is emitted per instance design that lives + /// in the manager's predefined-instance tree. Each wrapper exposes + /// typed accessor properties for its direct children (resolved via + /// the design Hierarchy): + /// + /// + /// variables → IVariableBuilder<T>; + /// methods → a per-method wrapper class with typed + /// OnCall(Func<…>) overloads; + /// objects → the matching child instance wrapper. + /// + /// + /// A top-level I{Manager}NodeManagerBuilder interface (extending + /// Opc.Ua.Server.Fluent.INodeManagerBuilder) carries one + /// accessor per top-level predefined instance. The + /// node-manager generator emits both + /// Configure(INodeManagerBuilder) and + /// Configure(I{Manager}NodeManagerBuilder) partials so existing + /// implementations continue to work unchanged. + /// + /// + internal sealed class FluentBuilderGenerator : IGenerator + { + /// + /// Optional override for the manager class name (matches the value + /// passed to ). + /// Defaults to {Prefix}NodeManager. + /// + public string OverrideManagerClassName { get; init; } + + /// + /// Optional override for the manager namespace (matches the value + /// passed to ). + /// Defaults to the model's target-namespace prefix. + /// + public string OverrideManagerNamespace { get; init; } + + /// + /// Initializes a new . + /// + public FluentBuilderGenerator(IGeneratorContext context) + { + m_context = context ?? throw new ArgumentNullException(nameof(context)); + } + + /// + public IEnumerable Emit() + { + string nsPrefix = m_context.ModelDesign.TargetNamespace.Prefix; + string typeStem = nsPrefix.Replace(".", string.Empty, StringComparison.Ordinal); + string outputNamespace = string.IsNullOrEmpty(OverrideManagerNamespace) + ? nsPrefix + : OverrideManagerNamespace; + string managerClassName = string.IsNullOrEmpty(OverrideManagerClassName) + ? typeStem + "NodeManager" + : OverrideManagerClassName; + string interfaceName = "I" + managerClassName + "Builder"; + string typedBuilderClassName = managerClassName + "TypedBuilder"; + + // Discover the top-level predefined instances. We always emit + // the typed manager interface and proxy class — even for + // models with no predefined instances — because the + // source-generated NodeManager partial unconditionally + // references them. + List roots = GetTopLevelInstances(); + + // Walk every instance under each root and assemble the wrapper + // class metadata. m_wrappers is keyed by SymbolicId path so + // child accessors can resolve their target wrapper by id. + m_wrappers = []; + m_methodWrappers = []; + foreach (InstanceDesign root in roots) + { + CollectInstanceWrappers(root); + } + + // Detect naming collisions per containing wrapper. Fail with a + // diagnostic (mirrored as an InvalidOperationException since we + // are running outside the Roslyn diagnostic pipeline here). + ValidateNoCollisions(); + + string fileStem = string.IsNullOrEmpty(OverrideManagerClassName) + ? nsPrefix + : OverrideManagerClassName; + string fileName = Path.Combine( + m_context.OutputFolder, + CoreUtils.Format("{0}.FluentBuilders.g.cs", fileStem)); + + using TextWriter writer = m_context.FileSystem.CreateTextWriter(fileName); + using var templateWriter = new TemplateWriter(writer); + var template = new Template(templateWriter, FluentBuilderTemplates.File); + + template.AddReplacement(Tokens.NamespacePrefix, outputNamespace); + + // Render the typed manager interface, the typed manager + // implementation, and one wrapper class per instance / method + // into the body slot. The body is emitted by a single + // load-template callback (the templating engine treats the + // returned null TemplateString as "nothing more to emit"). + object[] bodyTargets = ["body"]; + template.AddReplacement( + Tokens.ListOfTypes, + bodyTargets, + onLoad: ctx => + { + EmitManagerInterface(ctx.Out, interfaceName, roots); + EmitTypedManagerImpl( + ctx.Out, + interfaceName, + typedBuilderClassName, + managerClassName, + roots); + + foreach (InstanceWrapper wrapper in m_wrappers.Values + .OrderBy(w => w.ClassName, StringComparer.Ordinal)) + { + EmitInstanceWrapper(ctx.Out, wrapper); + } + + foreach (MethodWrapper method in m_methodWrappers.Values + .OrderBy(w => w.ClassName, StringComparer.Ordinal)) + { + EmitMethodWrapper(ctx.Out, method); + } + + return null; + }); + + template.Render(); + return [fileName.AsTextFileResource()]; + } + + // ============================================================ + // Discovery + // ============================================================ + + /// + /// Returns the model's top-level instance designs — those whose + /// is null and which sit in the + /// manager's predefined-instance tree (instances, not types). + /// + private List GetTopLevelInstances() + { + var result = new List(); + foreach (NodeDesign node in m_context.ModelDesign.GetNodeDesigns()) + { + if (node is not InstanceDesign instance) + { + continue; + } + if (instance.Parent != null) + { + continue; + } + if (m_context.ModelDesign.IsExcluded(instance)) + { + continue; + } + if (instance.NotInAddressSpace) + { + continue; + } + if (instance.IsDeclaration) + { + continue; + } + if (instance.Hierarchy == null) + { + continue; + } + result.Add(instance); + } + result.Sort(static (a, b) => string.CompareOrdinal( + a.SymbolicId?.Name, + b.SymbolicId?.Name)); + return result; + } + + /// + /// Walks 's hierarchy and registers a + /// wrapper for every non-method instance plus a method wrapper for + /// every method. + /// + private void CollectInstanceWrappers(InstanceDesign root) + { + if (root.Hierarchy == null) + { + return; + } + + // Build a parent-path → list of direct children mapping. The + // hierarchy keys are constructed by joining segments with + // ; segment names themselves + // can contain underscores (e.g. Boiler__1 for the + // browse name Boiler #1) so we cannot naively split on + // the path char. Instead we derive the parent path by stripping + // the child node's own SymbolicName.Name (plus the + // separator) from its RelativePath — the same trick + // used by . + var directChildren = new Dictionary>(StringComparer.Ordinal); + foreach (KeyValuePair entry in root.Hierarchy.Nodes) + { + string path = entry.Key ?? string.Empty; + if (path.Length == 0) + { + continue; + } + HierarchyNode hnode = entry.Value; + if (hnode?.Instance?.SymbolicName?.Name is not string segment || + segment.Length == 0) + { + continue; + } + int trimLen = segment.Length + 1; + string parent; + if (path.Length == segment.Length) + { + // Top-level child of the root (no preceding parent path). + parent = string.Empty; + } + else if (path.Length < trimLen) + { + // Malformed — skip. + continue; + } + else + { + parent = path[..^trimLen]; + } + if (!directChildren.TryGetValue(parent, out List bucket)) + { + bucket = []; + directChildren[parent] = bucket; + } + bucket.Add(hnode); + } + + // Emit one wrapper per non-method instance (root + descendants). + foreach (KeyValuePair entry in root.Hierarchy.Nodes) + { + if (entry.Value?.Instance is not NodeDesign nd) + { + continue; + } + if (m_context.ModelDesign.IsExcluded(nd)) + { + continue; + } + // Skip method instances — methods get their own wrapper + // class, registered separately below. + if (entry.Value.Instance is MethodDesign method) + { + RegisterMethodWrapper(root, entry.Key, method); + continue; + } + // Skip variables — they are surfaced as IVariableBuilder + // accessors on their parent wrapper, not as standalone + // wrapper classes. + if (entry.Value.Instance is VariableDesign) + { + continue; + } + if (entry.Value.Instance is not ObjectDesign) + { + continue; + } + RegisterInstanceWrapper(root, entry.Key, entry.Value, directChildren); + } + } + + /// + /// Registers an for the supplied + /// node. Idempotent — if the wrapper has already been registered + /// (e.g. via a sibling root) the existing entry is reused. + /// + private void RegisterInstanceWrapper( + InstanceDesign root, + string relativePath, + HierarchyNode hnode, + Dictionary> directChildren) + { + string key = ComposeKey(root, relativePath); + if (m_wrappers.ContainsKey(key)) + { + return; + } + + string className = ComposeClassName(root, relativePath, suffix: "Builder"); + string nsUri = ResolveNodeBrowseNamespace(hnode.Instance); + var wrapper = new InstanceWrapper + { + Key = key, + ClassName = className, + NodeStateType = ResolveStateClrType(hnode.Instance), + BrowseNamespaceUri = nsUri, + Children = [] + }; + + // Children resolved relative to this node. + if (directChildren.TryGetValue(relativePath, out List kids)) + { + foreach (HierarchyNode kid in kids) + { + if (kid?.Instance == null || m_context.ModelDesign.IsExcluded(kid.Instance)) + { + continue; + } + string childKey = ComposeKey(root, kid.RelativePath); + string accessorName = GetAccessorName(kid.Instance); + string browseName = GetBrowseName(kid.Instance); + string browseNsUri = ResolveNodeBrowseNamespace(kid.Instance); + var child = new ChildAccessor + { + AccessorName = accessorName, + BrowseName = browseName, + BrowseNamespaceUri = browseNsUri + }; + + switch (kid.Instance) + { + case VariableDesign var: + child.Kind = ChildKind.Variable; + child.ValueClrType = GetVariableValueClrType(var); + break; + case MethodDesign method: + child.Kind = ChildKind.Method; + child.WrapperClassName = ComposeClassName( + root, kid.RelativePath, suffix: "MethodBuilder"); + break; + case ObjectDesign: + child.Kind = ChildKind.Object; + child.WrapperClassName = ComposeClassName( + root, kid.RelativePath, suffix: "Builder"); + child.ChildKey = childKey; + child.ChildStateType = ResolveStateClrType(kid.Instance); + break; + default: + // Properties / unknowns: skip for v1. + continue; + } + wrapper.Children.Add(child); + } + } + + m_wrappers[key] = wrapper; + } + + /// + /// Registers a for the supplied method + /// design. Resolves typed argument shapes from + /// / + /// . + /// + private void RegisterMethodWrapper( + InstanceDesign root, + string relativePath, + MethodDesign method) + { + string key = ComposeKey(root, relativePath); + if (m_methodWrappers.ContainsKey(key)) + { + return; + } + + string className = ComposeClassName(root, relativePath, suffix: "MethodBuilder"); + var wrapper = new MethodWrapper + { + Key = key, + ClassName = className, + Inputs = method.InputArguments ?? [], + Outputs = method.OutputArguments ?? [] + }; + m_methodWrappers[key] = wrapper; + } + + // ============================================================ + // Validation + // ============================================================ + + /// + /// Verifies that no two children of the same wrapper sanitize to + /// the same C# accessor identifier. + /// + private void ValidateNoCollisions() + { + foreach (InstanceWrapper wrapper in m_wrappers.Values) + { + var seen = new Dictionary(StringComparer.Ordinal); + foreach (ChildAccessor child in wrapper.Children) + { + if (seen.TryGetValue(child.AccessorName, out ChildAccessor existing)) + { + throw new InvalidOperationException(CoreUtils.Format( + "Fluent builder generation: children '{0}' and '{1}' on '{2}' both sanitize to the same C# accessor '{3}'. Rename one of the children in the design.", + existing.BrowseName, + child.BrowseName, + wrapper.ClassName, + child.AccessorName)); + } + seen[child.AccessorName] = child; + } + } + } + + // ============================================================ + // Emission + // ============================================================ + + /// + /// Emits the typed manager interface declaration with one accessor + /// per top-level predefined instance. + /// + private void EmitManagerInterface( + ITemplateWriter writer, + string interfaceName, + IReadOnlyList roots) + { + writer.WriteLine(); + writer.WriteLine("/// "); + writer.WriteLine( + "/// Source-generated typed sibling of" + + " "); + writer.WriteLine( + "/// that surfaces typed accessors for the predefined-instance tree."); + writer.WriteLine("/// "); + writer.WriteLine( + "[global::System.CodeDom.Compiler.GeneratedCodeAttribute(\"{0}\", \"{1}\")]", + ToolName, + ToolVersion); + writer.WriteLine("internal interface {0} : global::Opc.Ua.Server.Fluent.INodeManagerBuilder", interfaceName); + writer.WriteLine("{"); + foreach (InstanceDesign root in roots) + { + string accessor = GetAccessorName(root); + string wrapperKey = ComposeKey(root, string.Empty); + if (!m_wrappers.TryGetValue(wrapperKey, out InstanceWrapper wrapper)) + { + continue; + } + writer.WriteLine(" /// Resolves the predefined instance {0}.", + GetBrowseName(root)); + writer.WriteLine(" {0} {1} {{ get; }}", wrapper.ClassName, accessor); + } + writer.WriteLine("}"); + } + + /// + /// Emits the internal sealed implementation that wraps the runtime + /// fluent NodeManagerBuilder and adds typed top-level + /// accessors. + /// + private void EmitTypedManagerImpl( + ITemplateWriter writer, + string interfaceName, + string className, + string managerClassName, + IReadOnlyList roots) + { + writer.WriteLine(); + writer.WriteLine("/// "); + writer.WriteLine( + "/// Internal proxy that wraps the runtime fluent" + + " NodeManagerBuilder"); + writer.WriteLine("/// to surface the typed facade.", interfaceName); + writer.WriteLine("/// "); + writer.WriteLine( + "[global::System.CodeDom.Compiler.GeneratedCodeAttribute(\"{0}\", \"{1}\")]", + ToolName, + ToolVersion); + writer.WriteLine("internal sealed class {0} : {1}", className, interfaceName); + writer.WriteLine("{"); + writer.WriteLine( + " private readonly global::Opc.Ua.Server.Fluent.INodeManagerBuilder __inner;"); + writer.WriteLine(); + writer.WriteLine( + " internal {0}(global::Opc.Ua.Server.Fluent.INodeManagerBuilder inner)", + className); + writer.WriteLine(" {"); + writer.WriteLine( + " __inner = inner ?? throw new global::System.ArgumentNullException(nameof(inner));"); + writer.WriteLine(" }"); + writer.WriteLine(); + + // Pass-through INodeManagerBuilder members. Each line is broken + // explicitly to satisfy the repository line-length analyzer + // (RCS0056, max 120 chars). + EmitPassThroughProperty(writer, "global::Opc.Ua.ISystemContext", "Context"); + EmitPassThroughProperty(writer, "global::Opc.Ua.Server.IAsyncNodeManager", "NodeManager"); + EmitPassThroughProperty(writer, "global::Opc.Ua.Server.Fluent.IFluentDispatcher", "Dispatcher"); + + EmitPassThroughMethod(writer, + "global::Opc.Ua.Server.Fluent.INodeBuilder", "Node", + "string browsePath", "browsePath"); + EmitPassThroughGenericMethod(writer, + "global::Opc.Ua.Server.Fluent.INodeBuilder", "Node", + "string browsePath", "browsePath"); + EmitPassThroughMethod(writer, + "global::Opc.Ua.Server.Fluent.INodeBuilder", "Node", + "global::Opc.Ua.NodeId nodeId", "nodeId"); + EmitPassThroughGenericMethod(writer, + "global::Opc.Ua.Server.Fluent.INodeBuilder", "Node", + "global::Opc.Ua.NodeId nodeId", "nodeId"); + + EmitPassThroughMethod(writer, + "global::Opc.Ua.Server.Fluent.INodeBuilder", "NodeFromTypeId", + "global::Opc.Ua.NodeId typeDefinitionId", "typeDefinitionId"); + EmitPassThroughMethod(writer, + "global::Opc.Ua.Server.Fluent.INodeBuilder", "NodeFromTypeId", + "global::Opc.Ua.NodeId typeDefinitionId, global::Opc.Ua.QualifiedName browseName", + "typeDefinitionId, browseName"); + EmitPassThroughGenericMethod(writer, + "global::Opc.Ua.Server.Fluent.INodeBuilder", "NodeFromTypeId", + "global::Opc.Ua.NodeId typeDefinitionId", "typeDefinitionId"); + EmitPassThroughGenericMethod(writer, + "global::Opc.Ua.Server.Fluent.INodeBuilder", "NodeFromTypeId", + "global::Opc.Ua.NodeId typeDefinitionId, global::Opc.Ua.QualifiedName browseName", + "typeDefinitionId, browseName"); + + EmitPassThroughGenericMethod(writer, + "global::Opc.Ua.Server.Fluent.IVariableBuilder", "Variable", + "string browsePath", "browsePath", typeArg: "TValue", noConstraint: true); + EmitPassThroughGenericMethod(writer, + "global::Opc.Ua.Server.Fluent.IVariableBuilder", "Variable", + "global::Opc.Ua.NodeId nodeId", "nodeId", typeArg: "TValue", noConstraint: true); + EmitPassThroughGenericMethod(writer, + "global::Opc.Ua.Server.Fluent.IVariableBuilder", "VariableFromTypeId", + "global::Opc.Ua.NodeId typeDefinitionId", "typeDefinitionId", + typeArg: "TValue", noConstraint: true); + EmitPassThroughGenericMethod(writer, + "global::Opc.Ua.Server.Fluent.IVariableBuilder", "VariableFromTypeId", + "global::Opc.Ua.NodeId typeDefinitionId, global::Opc.Ua.QualifiedName browseName", + "typeDefinitionId, browseName", + typeArg: "TValue", noConstraint: true); + + // Typed top-level accessors. + foreach (InstanceDesign root in roots) + { + string accessor = GetAccessorName(root); + string wrapperKey = ComposeKey(root, string.Empty); + if (!m_wrappers.TryGetValue(wrapperKey, out InstanceWrapper wrapper)) + { + continue; + } + string browseName = GetBrowseName(root); + string nsUri = ResolveNodeBrowseNamespace(root); + writer.WriteLine(); + writer.WriteLine(" /// "); + writer.WriteLine(" public {0} {1}", wrapper.ClassName, accessor); + writer.WriteLine(" {"); + writer.WriteLine(" get"); + writer.WriteLine(" {"); + writer.WriteLine(" ushort __ns = __inner.Context.NamespaceUris.GetIndexOrAppend(\"{0}\");", + EscapeStringLiteral(nsUri)); + writer.WriteLine(" return new {0}(__inner.Node<{1}>(new global::Opc.Ua.NodeId({2}, __ns)));", + wrapper.ClassName, + wrapper.NodeStateType, + EmitNodeIdConstructorArg(root)); + writer.WriteLine(" }"); + writer.WriteLine(" }"); + } + + writer.WriteLine("}"); + } + + /// + /// Emits a {Type} {Name} => __inner.{Name}; property forwarder + /// across two output lines so the result fits within RCS0056's 120 + /// character ceiling for any plausible runtime type name. + /// + private static void EmitPassThroughProperty( + ITemplateWriter writer, + string returnType, + string memberName) + { + writer.WriteLine(); + writer.WriteLine(" /// "); + writer.WriteLine(" public {0} {1}", returnType, memberName); + writer.WriteLine(" => __inner.{0};", memberName); + } + + /// + /// Emits a non-generic pass-through method forwarder broken across + /// multiple lines (signature, opener, body, closer). + /// + private static void EmitPassThroughMethod( + ITemplateWriter writer, + string returnType, + string memberName, + string parameterList, + string callArguments) + { + writer.WriteLine(); + writer.WriteLine(" /// "); + writer.WriteLine(" public {0} {1}({2})", returnType, memberName, parameterList); + writer.WriteLine(" => __inner.{0}({1});", memberName, callArguments); + } + + /// + /// Emits a generic pass-through method forwarder broken across + /// multiple lines. Defaults to where TState : NodeState; + /// pass = true to suppress + /// the constraint and to use a custom + /// type-parameter name (e.g. TValue). + /// + private static void EmitPassThroughGenericMethod( + ITemplateWriter writer, + string returnType, + string memberName, + string parameterList, + string callArguments, + string typeArg = "TState", + bool noConstraint = false) + { + writer.WriteLine(); + writer.WriteLine(" /// "); + writer.WriteLine(" public {0} {1}<{2}>({3})", + returnType, memberName, typeArg, parameterList); + if (!noConstraint) + { + writer.WriteLine(" where {0} : global::Opc.Ua.NodeState", typeArg); + } + writer.WriteLine(" => __inner.{0}<{1}>({2});", + memberName, typeArg, callArguments); + } + + /// + /// Emits one wrapper class for an + /// describing a non-method instance. + /// + private void EmitInstanceWrapper(ITemplateWriter writer, InstanceWrapper wrapper) + { + writer.WriteLine(); + writer.WriteLine("/// Typed wrapper for the predefined instance."); + writer.WriteLine( + "[global::System.CodeDom.Compiler.GeneratedCodeAttribute(\"{0}\", \"{1}\")]", + ToolName, + ToolVersion); + writer.WriteLine("internal sealed class {0}", wrapper.ClassName); + writer.WriteLine("{"); + writer.WriteLine(" private readonly global::Opc.Ua.Server.Fluent.INodeBuilder<{0}> __node;", + wrapper.NodeStateType); + writer.WriteLine(); + writer.WriteLine(" internal {0}(global::Opc.Ua.Server.Fluent.INodeBuilder<{1}> node)", + wrapper.ClassName, wrapper.NodeStateType); + writer.WriteLine(" {"); + writer.WriteLine(" __node = node ?? throw new global::System.ArgumentNullException(nameof(node));"); + writer.WriteLine(" }"); + writer.WriteLine(); + writer.WriteLine(" /// Underlying typed node builder."); + writer.WriteLine(" public global::Opc.Ua.Server.Fluent.INodeBuilder<{0}> Builder => __node;", + wrapper.NodeStateType); + writer.WriteLine(); + writer.WriteLine(" /// Resolved underlying node."); + writer.WriteLine(" public {0} Node => __node.Node;", wrapper.NodeStateType); + + foreach (ChildAccessor child in wrapper.Children) + { + EmitChildAccessor(writer, child); + } + + writer.WriteLine("}"); + } + + /// + /// Emits one accessor property on the parent wrapper. + /// + private void EmitChildAccessor(ITemplateWriter writer, ChildAccessor child) + { + writer.WriteLine(); + switch (child.Kind) + { + case ChildKind.Variable: + writer.WriteLine(" /// Typed accessor for variable child {0}.", + child.BrowseName); + writer.WriteLine(" public global::Opc.Ua.Server.Fluent.IVariableBuilder<{0}> {1}", + child.ValueClrType, child.AccessorName); + writer.WriteLine(" {"); + writer.WriteLine(" get"); + writer.WriteLine(" {"); + writer.WriteLine(" ushort __ns = __node.Builder.Context.NamespaceUris.GetIndexOrAppend(\"{0}\");", + EscapeStringLiteral(child.BrowseNamespaceUri)); + writer.WriteLine(" return __node.Variable<{0}>(new global::Opc.Ua.QualifiedName(\"{1}\", __ns));", + child.ValueClrType, + EscapeStringLiteral(child.BrowseName)); + writer.WriteLine(" }"); + writer.WriteLine(" }"); + break; + case ChildKind.Method: + writer.WriteLine(" /// Typed accessor for method child {0}.", + child.BrowseName); + writer.WriteLine(" public {0} {1}", child.WrapperClassName, child.AccessorName); + writer.WriteLine(" {"); + writer.WriteLine(" get"); + writer.WriteLine(" {"); + writer.WriteLine(" ushort __ns = __node.Builder.Context.NamespaceUris.GetIndexOrAppend(\"{0}\");", + EscapeStringLiteral(child.BrowseNamespaceUri)); + writer.WriteLine(" return new {0}(__node.Child(new global::Opc.Ua.QualifiedName(\"{1}\", __ns)));", + child.WrapperClassName, + EscapeStringLiteral(child.BrowseName)); + writer.WriteLine(" }"); + writer.WriteLine(" }"); + break; + case ChildKind.Object: + writer.WriteLine(" /// Typed accessor for object child {0}.", + child.BrowseName); + writer.WriteLine(" public {0} {1}", child.WrapperClassName, child.AccessorName); + writer.WriteLine(" {"); + writer.WriteLine(" get"); + writer.WriteLine(" {"); + writer.WriteLine(" ushort __ns = __node.Builder.Context.NamespaceUris.GetIndexOrAppend(\"{0}\");", + EscapeStringLiteral(child.BrowseNamespaceUri)); + writer.WriteLine(" return new {0}(__node.Child<{1}>(new global::Opc.Ua.QualifiedName(\"{2}\", __ns)));", + child.WrapperClassName, + child.ChildStateType, + EscapeStringLiteral(child.BrowseName)); + writer.WriteLine(" }"); + writer.WriteLine(" }"); + break; + } + } + + /// + /// Emits one wrapper class for a method instance with typed + /// OnCall overloads. + /// + private void EmitMethodWrapper(ITemplateWriter writer, MethodWrapper method) + { + writer.WriteLine(); + writer.WriteLine("/// Typed method-call wrapper for the predefined method."); + writer.WriteLine( + "[global::System.CodeDom.Compiler.GeneratedCodeAttribute(\"{0}\", \"{1}\")]", + ToolName, + ToolVersion); + writer.WriteLine("internal sealed class {0}", method.ClassName); + writer.WriteLine("{"); + writer.WriteLine(" private readonly global::Opc.Ua.Server.Fluent.INodeBuilder __node;"); + writer.WriteLine(); + writer.WriteLine(" internal {0}(global::Opc.Ua.Server.Fluent.INodeBuilder node)", + method.ClassName); + writer.WriteLine(" {"); + writer.WriteLine(" __node = node ?? throw new global::System.ArgumentNullException(nameof(node));"); + writer.WriteLine(" }"); + writer.WriteLine(); + writer.WriteLine(" /// Underlying typed node builder. Use to drop into the non-typed fluent surface."); + writer.WriteLine(" public global::Opc.Ua.Server.Fluent.INodeBuilder Builder => __node;"); + writer.WriteLine(); + writer.WriteLine(" /// Resolved underlying method state."); + writer.WriteLine(" public global::Opc.Ua.MethodState Node => __node.Node;"); + + // Sync typed OnCall. + EmitMethodOnCall(writer, method, async: false); + // Async typed OnCall. + EmitMethodOnCall(writer, method, async: true); + + writer.WriteLine("}"); + } + + /// + /// Emits one OnCall overload for a method wrapper. The overload + /// shape is determined by the method's argument signature plus the + /// requested sync/async flavor. + /// + private void EmitMethodOnCall(ITemplateWriter writer, MethodWrapper method, bool async) + { + string targetNamespace = m_context.ModelDesign.TargetNamespace.Value; + Namespace[] namespaces = m_context.ModelDesign.Namespaces; + Parameter[] inputs = method.Inputs; + Parameter[] outputs = method.Outputs; + + string returnTypeAnnotation = GetReturnTypeAnnotation(outputs, targetNamespace, namespaces); + string handlerType; + if (async) + { + if (inputs.Length == 0 && outputs.Length == 0) + { + handlerType = "global::System.Func"; + } + else if (outputs.Length == 0) + { + handlerType = CoreUtils.Format( + "global::System.Func<{0}, global::System.Threading.CancellationToken, global::System.Threading.Tasks.ValueTask>", + FormatInputTypeList(inputs, targetNamespace, namespaces)); + } + else + { + handlerType = CoreUtils.Format( + "global::System.Func<{0}{1}global::System.Threading.CancellationToken, global::System.Threading.Tasks.ValueTask{2}>", + FormatInputTypeList(inputs, targetNamespace, namespaces), + inputs.Length == 0 ? string.Empty : ", ", + returnTypeAnnotation); + } + } + else + { + if (inputs.Length == 0 && outputs.Length == 0) + { + handlerType = "global::System.Action"; + } + else if (outputs.Length == 0) + { + handlerType = CoreUtils.Format( + "global::System.Action<{0}>", + FormatInputTypeList(inputs, targetNamespace, namespaces)); + } + else + { + handlerType = CoreUtils.Format( + "global::System.Func<{0}{1}{2}>", + FormatInputTypeList(inputs, targetNamespace, namespaces), + inputs.Length == 0 ? string.Empty : ", ", + StripAngleBrackets(returnTypeAnnotation, defaultIfEmpty: "void")); + } + } + + writer.WriteLine(); + writer.WriteLine(" /// Wires the method-call handler ({0}).", + async ? "async" : "sync"); + writer.WriteLine(" public {0} OnCall({1} handler)", method.ClassName, handlerType); + writer.WriteLine(" {"); + writer.WriteLine(" if (handler == null) throw new global::System.ArgumentNullException(nameof(handler));"); + if (async) + { + writer.WriteLine(" __node.OnCall(async ("); + writer.WriteLine(" global::Opc.Ua.ISystemContext __ctx,"); + writer.WriteLine(" global::Opc.Ua.MethodState __m,"); + writer.WriteLine(" global::Opc.Ua.NodeId __oid,"); + writer.WriteLine(" global::Opc.Ua.ArrayOf __inputs,"); + writer.WriteLine(" global::System.Collections.Generic.List __outputs,"); + writer.WriteLine(" global::System.Threading.CancellationToken __ct) =>"); + writer.WriteLine(" {"); + } + else + { + writer.WriteLine(" __node.OnCall(("); + writer.WriteLine(" global::Opc.Ua.ISystemContext __ctx,"); + writer.WriteLine(" global::Opc.Ua.MethodState __m,"); + writer.WriteLine(" global::Opc.Ua.NodeId __oid,"); + writer.WriteLine(" global::Opc.Ua.ArrayOf __inputs,"); + writer.WriteLine(" global::System.Collections.Generic.List __outputs) =>"); + writer.WriteLine(" {"); + } + + // Validate input arg count. + if (inputs.Length > 0) + { + writer.WriteLine(" if (__inputs.Count < {0})", inputs.Length); + writer.WriteLine(" {"); + writer.WriteLine(" return new global::Opc.Ua.ServiceResult(global::Opc.Ua.StatusCodes.BadArgumentsMissing);"); + writer.WriteLine(" }"); + } + + // Unpack inputs. + for (int ii = 0; ii < inputs.Length; ii++) + { + EmitInputUnpack(writer, inputs[ii], ii, targetNamespace, namespaces); + } + + // Invoke user handler. + if (async) + { + if (outputs.Length == 0) + { + writer.Write(" await handler("); + EmitInputArgPassThrough(writer, inputs, withCt: true); + writer.WriteLine(").ConfigureAwait(false);"); + } + else if (outputs.Length == 1) + { + writer.Write(" var __r = await handler("); + EmitInputArgPassThrough(writer, inputs, withCt: true); + writer.WriteLine(").ConfigureAwait(false);"); + } + else + { + writer.Write(" var __r = await handler("); + EmitInputArgPassThrough(writer, inputs, withCt: true); + writer.WriteLine(").ConfigureAwait(false);"); + } + } + else + { + if (outputs.Length == 0) + { + writer.Write(" handler("); + EmitInputArgPassThrough(writer, inputs, withCt: false); + writer.WriteLine(");"); + } + else + { + writer.Write(" var __r = handler("); + EmitInputArgPassThrough(writer, inputs, withCt: false); + writer.WriteLine(");"); + } + } + + // Marshal outputs. + for (int ii = 0; ii < outputs.Length; ii++) + { + EmitOutputBox(writer, outputs[ii], ii, outputs.Length); + } + + writer.WriteLine(" return global::Opc.Ua.ServiceResult.Good;"); + if (async) + { + writer.WriteLine(" });"); + } + else + { + writer.WriteLine(" });"); + } + writer.WriteLine(" return this;"); + writer.WriteLine(" }"); + } + + /// + /// Emits the typed unpack code for a single input argument. Mirrors + /// the logic in . + /// + private static void EmitInputUnpack( + ITemplateWriter writer, + Parameter input, + int index, + string targetNamespace, + Namespace[] namespaces) + { + string typeName = input.DataTypeNode.GetMethodArgumentTypeAsCode( + input.ValueRank, + targetNamespace, + namespaces, + input.IsOptional); + string local = "__a" + index; + switch (input.DataTypeNode.BasicDataType) + { + case BasicDataType.UserDefined: + writer.WriteLine(" if (!__inputs[{0}].TryGetStructure(out {1} {2}))", + index, typeName, local); + writer.WriteLine(" {"); + writer.WriteLine(" return new global::Opc.Ua.ServiceResult(global::Opc.Ua.StatusCodes.BadInvalidArgument);"); + writer.WriteLine(" }"); + break; + case BasicDataType.BaseDataType when input.ValueRank == ValueRank.Scalar: + writer.WriteLine(" {0} {1} = __inputs[{2}];", typeName, local, index); + break; + default: + writer.WriteLine(" if (!__inputs[{0}].TryGetValue(out {1} {2}))", + index, typeName, local); + writer.WriteLine(" {"); + writer.WriteLine(" return new global::Opc.Ua.ServiceResult(global::Opc.Ua.StatusCodes.BadInvalidArgument);"); + writer.WriteLine(" }"); + break; + } + } + + /// + /// Emits the comma-separated list of unpacked input locals (plus + /// optional CancellationToken) for the user-handler invocation. + /// + private static void EmitInputArgPassThrough( + ITemplateWriter writer, + Parameter[] inputs, + bool withCt) + { + for (int ii = 0; ii < inputs.Length; ii++) + { + if (ii > 0) + { + writer.Write(", "); + } + writer.Write("__a" + ii); + } + if (withCt) + { + if (inputs.Length > 0) + { + writer.Write(", "); + } + writer.Write("__ct"); + } + } + + /// + /// Emits the boxing code for a single output argument. For multi- + /// output methods the user returns a ValueTuple and we + /// destructure by field name (Item1, Item2, …). + /// + private static void EmitOutputBox( + ITemplateWriter writer, + Parameter output, + int index, + int totalOutputs) + { + string source; + if (totalOutputs == 1) + { + source = "__r"; + } + else + { + source = "__r.Item" + (index + 1).ToString(System.Globalization.CultureInfo.InvariantCulture); + } + + switch (output.DataTypeNode.BasicDataType) + { + case BasicDataType.UserDefined: + writer.WriteLine(" __outputs.Add(global::Opc.Ua.Variant.FromStructure({0}));", source); + break; + case BasicDataType.BaseDataType when output.ValueRank == ValueRank.Scalar: + writer.WriteLine(" __outputs.Add({0});", source); + break; + default: + writer.WriteLine(" __outputs.Add(global::Opc.Ua.Variant.From({0}));", source); + break; + } + } + + /// + /// Returns the ValueTask<…> suffix used in the method's + /// async return type. Mirrors + /// verbatim. + /// + private static string GetReturnTypeAnnotation( + Parameter[] outputs, + string targetNamespace, + Namespace[] namespaces) + { + if (outputs.Length == 0) + { + return string.Empty; + } + if (outputs.Length == 1) + { + return CoreUtils.Format( + "<{0}>", + outputs[0].DataTypeNode.GetMethodArgumentTypeAsCode( + outputs[0].ValueRank, + targetNamespace, + namespaces, + outputs[0].IsOptional)); + } + var sb = new System.Text.StringBuilder(); + sb.Append("<("); + for (int ii = 0; ii < outputs.Length; ii++) + { + if (ii > 0) + { + sb.Append(", "); + } + sb.Append(outputs[ii].DataTypeNode.GetMethodArgumentTypeAsCode( + outputs[ii].ValueRank, + targetNamespace, + namespaces, + outputs[ii].IsOptional)); + sb.Append(" Item" + (ii + 1).ToString(System.Globalization.CultureInfo.InvariantCulture)); + } + sb.Append(")>"); + return sb.ToString(); + } + + private static string StripAngleBrackets(string returnTypeAnnotation, string defaultIfEmpty) + { + if (string.IsNullOrEmpty(returnTypeAnnotation)) + { + return defaultIfEmpty; + } + // Strip leading '<' and trailing '>'. + return returnTypeAnnotation[1..^1]; + } + + private static string FormatInputTypeList( + Parameter[] inputs, + string targetNamespace, + Namespace[] namespaces) + { + var sb = new System.Text.StringBuilder(); + for (int ii = 0; ii < inputs.Length; ii++) + { + if (ii > 0) + { + sb.Append(", "); + } + sb.Append(inputs[ii].DataTypeNode.GetMethodArgumentTypeAsCode( + inputs[ii].ValueRank, + targetNamespace, + namespaces, + inputs[ii].IsOptional)); + } + return sb.ToString(); + } + + // ============================================================ + // Helpers + // ============================================================ + + /// + /// Returns the wrapper-key string used to deduplicate wrappers and + /// to compose CLR class names. Combines the root's symbolic id + /// with the relative path from the root. + /// + private static string ComposeKey(InstanceDesign root, string relativePath) + { + string rootId = root?.SymbolicId?.Name ?? string.Empty; + if (string.IsNullOrEmpty(relativePath)) + { + return rootId; + } + return rootId + "_" + relativePath; + } + + private static string ComposeClassName( + InstanceDesign root, + string relativePath, + string suffix) + { + string key = ComposeKey(root, relativePath); + return key + suffix; + } + + private static string GetAccessorName(NodeDesign node) + { + string name = node?.SymbolicName?.Name; + if (string.IsNullOrEmpty(name)) + { + return "Item"; + } + return name.ToSafeSymbolName(toLowerCamelCase: false); + } + + private static string GetBrowseName(NodeDesign node) + { + // Per the design schema the BrowseName is identical to the + // SymbolicName when it isn't explicitly overridden. Use the + // BrowseName when present so source-generated identifiers and + // the on-the-wire OPC UA browse name stay aligned. + if (!string.IsNullOrEmpty(node?.BrowseName)) + { + return node.BrowseName; + } + return node?.SymbolicName?.Name ?? string.Empty; + } + + private string ResolveNodeBrowseNamespace(NodeDesign node) + { + string ns = node?.SymbolicName?.Namespace; + if (!string.IsNullOrEmpty(ns)) + { + return ns; + } + return m_context.ModelDesign.TargetNamespace?.Value ?? string.Empty; + } + + /// + /// Returns the C# type name of the runtime + /// derivative for the supplied instance. + /// + private string ResolveStateClrType(NodeDesign node) + { + // For object instances, use BaseObjectState as the lowest common + // denominator. The user can call .Builder.As<TConcrete>() to + // narrow. + if (node is ObjectDesign) + { + return "global::Opc.Ua.BaseObjectState"; + } + if (node is MethodDesign) + { + return "global::Opc.Ua.MethodState"; + } + if (node is VariableDesign) + { + return "global::Opc.Ua.BaseDataVariableState"; + } + return "global::Opc.Ua.NodeState"; + } + + /// + /// Returns the CLR type name for a variable's value attribute, + /// inferred from the variable's DataType and ValueRank. + /// + private string GetVariableValueClrType(VariableDesign variable) + { + string targetNamespace = m_context.ModelDesign.TargetNamespace.Value; + Namespace[] namespaces = m_context.ModelDesign.Namespaces; + return variable.DataTypeNode.GetMethodArgumentTypeAsCode( + variable.ValueRank, + targetNamespace, + namespaces, + isOptional: false); + } + + /// + /// Emits the constructor argument used to materialize the + /// top-level instance's . Numeric ids preferred + /// when present; otherwise falls back to the SymbolicId string. + /// + private static string EmitNodeIdConstructorArg(InstanceDesign node) + { + if (node.NumericIdSpecified && node.NumericId != 0u) + { + return CoreUtils.Format("{0}u", + node.NumericId.ToString(System.Globalization.CultureInfo.InvariantCulture)); + } + if (!string.IsNullOrEmpty(node.StringId)) + { + return CoreUtils.Format("\"{0}\"", EscapeStringLiteral(node.StringId)); + } + // No id assigned — fall back to the SymbolicId.Name as a string id. + return CoreUtils.Format("\"{0}\"", + EscapeStringLiteral(node.SymbolicId?.Name ?? string.Empty)); + } + + private static string EscapeStringLiteral(string value) + { + if (string.IsNullOrEmpty(value)) + { + return string.Empty; + } + return value + .Replace("\\", "\\\\", StringComparison.Ordinal) + .Replace("\"", "\\\"", StringComparison.Ordinal); + } + + // ============================================================ + // State + // ============================================================ + + private readonly IGeneratorContext m_context; + private Dictionary m_wrappers = []; + private Dictionary m_methodWrappers = []; + + private static string ToolName + => System.Reflection.Assembly.GetExecutingAssembly().GetName().Name; + private static string ToolVersion + => System.Reflection.Assembly.GetExecutingAssembly().GetName().Version.ToString(); + + private enum ChildKind + { + Variable, + Method, + Object + } + + private sealed class InstanceWrapper + { + public string Key; + public string ClassName; + public string NodeStateType; + public string BrowseNamespaceUri; + public List Children; + } + + private sealed class ChildAccessor + { + public string AccessorName; + public string BrowseName; + public string BrowseNamespaceUri; + public ChildKind Kind; + public string ValueClrType; // Variable + public string WrapperClassName; // Method or Object + public string ChildKey; // Object — key into m_wrappers + public string ChildStateType; // Object — node state type + } + + private sealed class MethodWrapper + { + public string Key; + public string ClassName; + public Parameter[] Inputs; + public Parameter[] Outputs; + } + } +} diff --git a/Tools/Opc.Ua.SourceGeneration.Core/Generators/FluentBuilderTemplates.cs b/Tools/Opc.Ua.SourceGeneration.Core/Generators/FluentBuilderTemplates.cs new file mode 100644 index 0000000000..611366e13d --- /dev/null +++ b/Tools/Opc.Ua.SourceGeneration.Core/Generators/FluentBuilderTemplates.cs @@ -0,0 +1,66 @@ +/* ======================================================================== + * 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/ + * ======================================================================*/ + +namespace Opc.Ua.SourceGeneration +{ + /// + /// Top-level templates for the . + /// The bulk of the generated code is emitted directly via + /// calls from the generator (per-class, + /// per-accessor, per-method-overload bodies); only the file shell and + /// the boilerplate per-class skeleton live as template strings. + /// + internal static class FluentBuilderTemplates + { + /// + /// Single output file template. Hosts the typed manager interface + /// plus every per-type, per-instance and per-method wrapper class + /// generated for the model. + /// + public static readonly TemplateString File = TemplateString.Parse( + $$""" + {{Tokens.CodeHeader}} + + #pragma warning disable CS0419 // Ambiguous reference in cref attribute + #pragma warning disable CS1591 // Missing XML comment for publicly visible type or member + #pragma warning disable CA1707 // Identifiers should not contain underscores + #pragma warning disable CA1822 // Mark members as static + #pragma warning disable IDE0008 // Use explicit type + #pragma warning disable IDE1006 // Naming rule violation + + #nullable enable + + namespace {{Tokens.NamespacePrefix}} + { + {{Tokens.ListOfTypes}} + } + + """); + } +} diff --git a/Tools/Opc.Ua.SourceGeneration.Core/Generators/NodeManagerTemplates.cs b/Tools/Opc.Ua.SourceGeneration.Core/Generators/NodeManagerTemplates.cs index d3a85f2790..5a3de73678 100644 --- a/Tools/Opc.Ua.SourceGeneration.Core/Generators/NodeManagerTemplates.cs +++ b/Tools/Opc.Ua.SourceGeneration.Core/Generators/NodeManagerTemplates.cs @@ -79,6 +79,14 @@ public partial class {{Tokens.NodeManagerClassName}} : global::Opc.Ua.Server.Asy /// partial void Configure(global::Opc.Ua.Server.Fluent.INodeManagerBuilder builder); + /// + /// Source-generated typed counterpart of + /// . + /// Implement in a sibling partial to wire callbacks via the + /// strongly-typed model-traversal surface generated for this manager. + /// + partial void Configure(I{{Tokens.NodeManagerClassName}}Builder builder); + /// protected override global::System.Threading.Tasks.ValueTask LoadPredefinedNodesAsync( global::Opc.Ua.ISystemContext context, @@ -107,6 +115,7 @@ public partial class {{Tokens.NodeManagerClassName}} : global::Opc.Ua.Server.Asy __FindByTypeDefinitionId); Configure(__m_builder); + Configure(new {{Tokens.NodeManagerClassName}}TypedBuilder(__m_builder)); __m_builder.Seal(); foreach (global::Opc.Ua.NodeState __node in PredefinedNodes.Values) From a15e8d0ecf702e6d348d2067902fb668548d6ef1 Mon Sep 17 00:00:00 2001 From: Marc Schier Date: Tue, 12 May 2026 19:50:03 +0200 Subject: [PATCH 08/19] Add tests for the typed model-traversal source generator Adds 9 snapshot tests in Tests/Opc.Ua.SourceGeneration.Core.Tests covering the FluentBuilderGenerator's opt-in flag, typed-manager-interface and proxy emission, internal-sealed visibility, lazy child resolution via Context.NamespaceUris.GetIndexOrAppend, IVariableBuilder accessors, sync+async OnCall overloads on method wrappers, and the dual Configure(INodeManagerBuilder)/Configure(I{Manager}NodeManagerBuilder) partial wiring in the NodeManager template. Adds 2 end-to-end AOT tests in Tests/Opc.Ua.Aot.Tests that exercise typed-traversal sync read on LCX001/Measurement and typed-traversal async OnCall on Simulation/Halt through the AOT-compiled MinimalBoilerServer fixture. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../BoilerNodeManagerAotTests.cs | 51 ++++ .../Generators/FluentBuilderGeneratorTests.cs | 218 ++++++++++++++++++ 2 files changed, 269 insertions(+) create mode 100644 Tests/Opc.Ua.SourceGeneration.Core.Tests/Generators/FluentBuilderGeneratorTests.cs 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.SourceGeneration.Core.Tests/Generators/FluentBuilderGeneratorTests.cs b/Tests/Opc.Ua.SourceGeneration.Core.Tests/Generators/FluentBuilderGeneratorTests.cs new file mode 100644 index 0000000000..eca5c595d7 --- /dev/null +++ b/Tests/Opc.Ua.SourceGeneration.Core.Tests/Generators/FluentBuilderGeneratorTests.cs @@ -0,0 +1,218 @@ +/* ======================================================================== + * 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.IO; +using System.Linq; +using System.Text; +using Microsoft.Extensions.Logging; +using NUnit.Framework; +using Opc.Ua.Tests; + +namespace Opc.Ua.SourceGeneration.Generator.Tests +{ + /// + /// Snapshot tests for the : verify + /// the opt-in flag, the type-and-instance wrapper shape, the typed + /// IVariableBuilder accessors, and the typed sync/async OnCall + /// overloads on method wrappers. + /// + [TestFixture] + [Category("Generator")] + [SetCulture("en-us")] + [SetUICulture("en-us")] + [Parallelizable(ParallelScope.All)] + public class FluentBuilderGeneratorTests + { + [Test] + public void Emit_WithoutOptIn_ProducesNoFluentBuildersFile() + { + Dictionary files = GenerateForTestModel(generateNodeManager: false); + + Assert.That(files.Keys, Has.None.EndsWith(".FluentBuilders.g.cs")); + } + + [Test] + public void Emit_WithOptIn_ProducesFluentBuildersFile() + { + Dictionary files = GenerateForTestModel(generateNodeManager: true); + + Assert.That(files.Keys, Has.Some.EndsWith(".FluentBuilders.g.cs"), + "Generator should emit a FluentBuilders file when GenerateNodeManager=true"); + } + + [Test] + public void EmittedFluentBuilders_HasTypedManagerInterfaceAndProxy() + { + string fb = GetFluentBuilders(); + + Assert.That(fb, Does.Match( + @"internal\s+interface\s+I\w+NodeManagerBuilder\s*:\s*global::Opc\.Ua\.Server\.Fluent\.INodeManagerBuilder"), + "A typed IBuilder interface extending INodeManagerBuilder must be emitted"); + + Assert.That(fb, Does.Match( + @"internal\s+sealed\s+class\s+\w+NodeManagerTypedBuilder\s*:\s*I\w+NodeManagerBuilder"), + "A typed proxy implementing the IBuilder must be emitted"); + + Assert.That(fb, Does.Contain("private readonly global::Opc.Ua.Server.Fluent.INodeManagerBuilder __inner"), + "The proxy must wrap an INodeManagerBuilder"); + } + + [Test] + public void EmittedFluentBuilders_AreAutoGenerated_AndUseGlobalQualifiedTypes() + { + string fb = GetFluentBuilders(); + + Assert.That(fb, Does.StartWith("// "), + "FluentBuilders file must start with the auto-generated marker"); + Assert.That(fb, Does.Contain("[global::System.CodeDom.Compiler.GeneratedCodeAttribute("), + "FluentBuilders file must carry the GeneratedCode attribute"); + Assert.That(fb, Does.Contain("#pragma warning disable"), + "FluentBuilders file must suppress warnings to survive strict consumer projects"); + } + + [Test] + public void EmittedFluentBuilders_WrappersAreInternalSealed() + { + string fb = GetFluentBuilders(); + + // Per design decision #8 — wrappers are internal sealed because + // Configure is a private partial. Public exposure would add a + // surface that can never be called outside the assembly. + Assert.That(fb, Does.Match(@"internal\s+sealed\s+class\s+\w+Builder\b"), + "Wrapper classes should be 'internal sealed'"); + Assert.That(fb, Does.Not.Match(@"public\s+(sealed\s+)?class\s+\w+Builder\b"), + "Wrapper classes must not be public"); + } + + [Test] + public void EmittedFluentBuilders_VariableAccessorsReturnTypedIVariableBuilder() + { + string fb = GetFluentBuilders(); + + // TestModel's RestrictedObjectType has a Mandatory variable child + // 'Red' of type RestrictedVariableType (Int32). The wrapper for + // the predefined TestObject instance must surface that variable + // through a typed IVariableBuilder accessor. + Assert.That(fb, Does.Match( + @"public\s+global::Opc\.Ua\.Server\.Fluent\.IVariableBuilder<\w+>\s+\w+\s*\{"), + "Variable children should expose IVariableBuilder accessors"); + + // Each accessor lazily resolves the namespace index to support + // cross-namespace browse names (NamespaceUris.GetIndexOrAppend). + Assert.That(fb, Does.Contain("Context.NamespaceUris.GetIndexOrAppend("), + "Variable accessors should resolve the namespace via GetIndexOrAppend"); + Assert.That(fb, Does.Match( + @"return\s+__node\.Variable<\w+>\(new global::Opc\.Ua\.QualifiedName\("), + "Variable accessors should fall back to INodeBuilder.Variable()"); + } + + [Test] + public void EmittedFluentBuilders_MethodWrappersExposeSyncAndAsyncOnCall() + { + string fb = GetFluentBuilders(); + + // Every method wrapper must offer BOTH sync and async OnCall + // overloads so user code-behind can choose the natural shape. + Assert.That(fb, Does.Match( + @"public\s+\w+MethodBuilder\s+OnCall\s*\(\s*global::System\.Action\s+handler\s*\)"), + "Method wrappers should expose a sync OnCall(Action) overload"); + Assert.That(fb, Does.Match( + @"public\s+\w+MethodBuilder\s+OnCall\s*\(\s*global::System\.Func\s+handler\s*\)"), + "Method wrappers should expose an async OnCall(Func) overload"); + } + + [Test] + public void EmittedFluentBuilders_InstanceAccessorsAreLazyAndAllocateChildBuilder() + { + string fb = GetFluentBuilders(); + + // Child accessors should never cache — each get materializes a + // fresh wrapper around an INodeBuilder. Caching + // would be unsafe because INodeBuilder identity is per-resolution. + Assert.That(fb, Does.Not.Match(@"private\s+\w+Builder\s+__child"), + "Child accessors must not cache the wrapper"); + Assert.That(fb, Does.Match( + @"return\s+new\s+\w+Builder\s*\(\s*__node\.Child<\w+"), + "Object child accessors should resolve via INodeBuilder.Child()"); + } + + [Test] + public void EmittedNodeManager_InvokesBothPlainAndTypedConfigurePartials() + { + Dictionary files = GenerateForTestModel(generateNodeManager: true); + string mgr = files.Single(kv => kv.Key.EndsWith(".NodeManager.g.cs", StringComparison.Ordinal)).Value; + + // Both partials must be declared so users can implement either. + Assert.That(mgr, Does.Contain("partial void Configure(global::Opc.Ua.Server.Fluent.INodeManagerBuilder builder);"), + "Plain Configure(INodeManagerBuilder) partial declaration must be emitted"); + Assert.That(mgr, Does.Match(@"partial\s+void\s+Configure\s*\(\s*I\w+NodeManagerBuilder\s+builder\s*\);"), + "Typed Configure(I{Manager}NodeManagerBuilder) partial declaration must be emitted"); + + // Both must also be invoked so a user that only implements one + // gets their wiring run; the runtime treats unimplemented + // partials as no-ops. + Assert.That(mgr, Does.Contain("Configure(__m_builder);"), + "Plain Configure call must be present in CreateAddressSpaceAsync"); + Assert.That(mgr, Does.Match(@"Configure\(\s*new\s+\w+NodeManagerTypedBuilder\(__m_builder\)\s*\)"), + "Typed Configure call must be present in CreateAddressSpaceAsync"); + } + + private static string GetFluentBuilders() + { + Dictionary files = GenerateForTestModel(generateNodeManager: true); + return files.Single(kv => kv.Key.EndsWith(".FluentBuilders.g.cs", StringComparison.Ordinal)).Value; + } + + private static Dictionary GenerateForTestModel(bool generateNodeManager) + { + const string designFile = "TestModel.xml"; + ITelemetryContext telemetry = NUnitTelemetryContext.Create(logLevel: LogLevel.Error); + using var fileSystem = new VirtualFileSystem(); + string resources = Path.Combine(Directory.GetCurrentDirectory(), "Resources"); + + Generators.GenerateCode(new DesignFileCollection + { + Targets = [Path.Combine(resources, designFile)], + IdentifierFilePath = Path.Combine( + resources, + Path.GetFileNameWithoutExtension(designFile) + ".csv"), + Options = new DesignFileOptions + { + GenerateNodeManager = generateNodeManager + } + }, fileSystem, string.Empty, telemetry); + + return fileSystem.CreatedFiles + .Where(c => Path.GetExtension(c) == ".cs") + .ToDictionary(c => c, c => Encoding.UTF8.GetString(fileSystem.Get(c))); + } + } +} From da12a62abcf3577d09c7093c73afe467385a9269 Mon Sep 17 00:00:00 2001 From: Marc Schier Date: Tue, 12 May 2026 19:51:36 +0200 Subject: [PATCH 09/19] Document the typed model-traversal Configure(I{Manager}NodeManagerBuilder) partial Adds a section to Docs/SourceGeneratedNodeManagers.md walking through the source-generated typed builder: the second Configure partial whose builder parameter exposes IntelliSense accessors for every predefined instance, child, variable and method in the model. Includes a side-by-side untyped+typed example mirroring MinimalBoilerServer's BoilerNodeManager.Configure.cs and itemizes the per-model emit (interface, proxy, instance wrappers, method wrappers) so consumers know exactly what surface ships in {Manager}.FluentBuilders.g.cs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Docs/SourceGeneratedNodeManagers.md | 76 +++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/Docs/SourceGeneratedNodeManagers.md b/Docs/SourceGeneratedNodeManagers.md index ec65974138..92537b9812 100644 --- a/Docs/SourceGeneratedNodeManagers.md +++ b/Docs/SourceGeneratedNodeManagers.md @@ -202,6 +202,82 @@ 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 sync + `OnCall(Action)` and async + `OnCall(Func)` overloads. (Method + overloads with input/output arguments are unboxed and re-boxed by + the generator using the same `Variant.TryGetValue` / + `Variant.From` pattern as the client-side `[ObjectType]` proxies.) + +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. + ## Single-file `Program.cs` — what it looks like The shipping `Opc.Ua.Server.Hosting.AddOpcUaServer(...)` extension wires the From f0b22dea19556adb45acd0762a482cdd4a6093cc Mon Sep 17 00:00:00 2001 From: Marc Schier Date: Wed, 13 May 2026 08:57:59 +0200 Subject: [PATCH 10/19] Fix ConnectAndCloseSessionAsync AOT test The AOT integration test `ConnectAndCloseSessionAsync` failed because `SessionManager.CloseSessionAsync` (server-side) had its `ConcurrentDictionary.TryRemove` condition inverted in commit 70b4498db8: when the session was successfully removed the method returned without actually closing it, and a stray `Debug.Assert(session == null)` was placed on the success branch where `session` is in fact the just-removed value. The assertion was previously invisible because `Debug.Assert` only logs on most runners, but TUnit installs a `ThrowListener` that converts `Debug.Fail` calls into thrown `TUnitException` instances, which the server then surfaces as `BadUnexpectedError` in the `CloseSession` response. `Session.CloseAsync` propagates that status to the caller, so the client test's `Assert.That(result).IsEqualTo(StatusCodes.Good)` failed. Restore the original logic: when `TryRemove` fails the entry was already removed by a concurrent caller (so `session` stays null) and the method bails; when it succeeds the loop breaks and the code below disposes the session and updates the diagnostics counters. After the fix the previously failing test passes and all 73 AOT tests succeed; `Opc.Ua.Server.Tests` Fluent tests (82) also pass on net10.0. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Libraries/Opc.Ua.Server/Session/SessionManager.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Libraries/Opc.Ua.Server/Session/SessionManager.cs b/Libraries/Opc.Ua.Server/Session/SessionManager.cs index 201c1af7c1..2c8447790e 100644 --- a/Libraries/Opc.Ua.Server/Session/SessionManager.cs +++ b/Libraries/Opc.Ua.Server/Session/SessionManager.cs @@ -491,10 +491,10 @@ public virtual async ValueTask CloseSessionAsync(NodeId sessionId, CancellationT if (current.Value.Id == sessionId) { #pragma warning disable CA2000 // Disposed correctly later - if (m_sessions.TryRemove(current.Key, out session)) + if (!m_sessions.TryRemove(current.Key, out session)) #pragma warning restore CA2000 { - // found but was already removed + // found but already removed by a concurrent caller System.Diagnostics.Debug.Assert(session == null); return; } From ebbdf4c989dae4497151769169cc4c2ff5dd6157 Mon Sep 17 00:00:00 2001 From: Marc Schier Date: Wed, 13 May 2026 09:22:26 +0200 Subject: [PATCH 11/19] Emit fluent wrapper classes as nested types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactors the FluentBuilderGenerator to emit each per-instance and per-method wrapper as a nested type inside its lexical parent's wrapper, instead of as a flat namespace-scope class with a long underscore-joined name (e.g. Boilers_Boiler__1_PipeX001_FTX001Builder). The user-facing fluent surface is unchanged because property names on accessors do not change — only the underlying class names. Changes: - Add LeafName / ParentKey / ChildObjectKeys / ChildMethodKeys to InstanceWrapper and LeafName / ParentKey to MethodWrapper. - Replace ComposeClassName with ResolveLeafName, ResolveParentKey and ComposeWrapperClassName helpers. - Add LinkChildWrappers post-pass to wire each parent to its direct child wrappers (sorted by ordinal leaf name). - Refactor Emit to walk only top-level wrappers and recurse via the new ChildObjectKeys / ChildMethodKeys. - Thread an explicit indent parameter through EmitInstanceWrapper, EmitChildAccessor, EmitMethodWrapper, EmitMethodOnCall, EmitInputUnpack and EmitOutputBox so each nesting level adds another 4 spaces to every emitted line. Avoid the writer's Push/Pop indentation API which discards pending newlines. - Add three new snapshot tests: TopLevelInstanceWrapperLivesAt NamespaceScope, MethodWrapperIsNestedInsideOwningObject, and ChildAccessorReturnsSimpleLeafName. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Generators/FluentBuilderGeneratorTests.cs | 42 ++ .../Generators/FluentBuilderGenerator.cs | 516 ++++++++++++------ 2 files changed, 391 insertions(+), 167 deletions(-) diff --git a/Tests/Opc.Ua.SourceGeneration.Core.Tests/Generators/FluentBuilderGeneratorTests.cs b/Tests/Opc.Ua.SourceGeneration.Core.Tests/Generators/FluentBuilderGeneratorTests.cs index eca5c595d7..9072c8e9f8 100644 --- a/Tests/Opc.Ua.SourceGeneration.Core.Tests/Generators/FluentBuilderGeneratorTests.cs +++ b/Tests/Opc.Ua.SourceGeneration.Core.Tests/Generators/FluentBuilderGeneratorTests.cs @@ -164,6 +164,48 @@ public void EmittedFluentBuilders_InstanceAccessorsAreLazyAndAllocateChildBuilde "Object child accessors should resolve via INodeBuilder.Child()"); } + [Test] + public void EmittedFluentBuilders_TopLevelInstanceWrapperLivesAtNamespaceScope() + { + string fb = GetFluentBuilders(); + + // Top-level wrappers must sit directly under the namespace + // body — i.e. at column 4 (one level into the namespace block + // emitted by FluentBuilderTemplates.File). Use TestObject as + // an exemplar — TestModel.xml declares it as a top-level + // instance. + Assert.That(fb, Does.Match(@"(?m)^ internal sealed class TestObjectBuilder\b"), + "Top-level wrappers must be declared at namespace scope (column 4)"); + } + + [Test] + public void EmittedFluentBuilders_MethodWrapperIsNestedInsideOwningObject() + { + string fb = GetFluentBuilders(); + + // TestObject's RestrictedObjectType has a Mandatory Method + // child 'Blue'. Its wrapper class must be nested one level + // inside TestObjectBuilder — i.e. declared at column 8. + Assert.That(fb, Does.Match( + @"(?m)^ internal sealed class BlueMethodBuilder\b"), + "Method wrappers must be nested inside the owning object's wrapper (column 8)"); + } + + [Test] + public void EmittedFluentBuilders_ChildAccessorReturnsSimpleLeafName() + { + string fb = GetFluentBuilders(); + + // Child accessors should return the simple leaf name only + // (e.g. 'BlueMethodBuilder') because the wrapper is nested + // inside the parent, not the old flat + // 'TestObject_BlueMethodBuilder' identifier. + Assert.That(fb, Does.Not.Match(@"public\s+TestObject_\w+Builder\s+\w+"), + "Child accessors must reference the simple leaf-name wrapper, not a flat dotted identifier"); + Assert.That(fb, Does.Match(@"public\s+BlueMethodBuilder\s+Blue\b"), + "Child accessor for 'Blue' should return the simple-leaf-name 'BlueMethodBuilder'"); + } + [Test] public void EmittedNodeManager_InvokesBothPlainAndTypedConfigurePartials() { diff --git a/Tools/Opc.Ua.SourceGeneration.Core/Generators/FluentBuilderGenerator.cs b/Tools/Opc.Ua.SourceGeneration.Core/Generators/FluentBuilderGenerator.cs index f846d9fe13..1dc578c7db 100644 --- a/Tools/Opc.Ua.SourceGeneration.Core/Generators/FluentBuilderGenerator.cs +++ b/Tools/Opc.Ua.SourceGeneration.Core/Generators/FluentBuilderGenerator.cs @@ -126,6 +126,11 @@ public IEnumerable Emit() // are running outside the Roslyn diagnostic pipeline here). ValidateNoCollisions(); + // Wire each wrapper to its direct child object/method + // wrappers so the recursive emitter can walk the tree + // depth-first and emit nested type declarations. + LinkChildWrappers(); + string fileStem = string.IsNullOrEmpty(OverrideManagerClassName) ? nsPrefix : OverrideManagerClassName; @@ -158,16 +163,15 @@ public IEnumerable Emit() managerClassName, roots); - foreach (InstanceWrapper wrapper in m_wrappers.Values - .OrderBy(w => w.ClassName, StringComparer.Ordinal)) - { - EmitInstanceWrapper(ctx.Out, wrapper); - } - - foreach (MethodWrapper method in m_methodWrappers.Values - .OrderBy(w => w.ClassName, StringComparer.Ordinal)) + // Walk top-level instance wrappers depth-first so each + // child object/method wrapper is emitted as a nested + // type inside its parent. Top-level wrappers (those + // whose parent path is empty) live at namespace scope. + foreach (InstanceWrapper top in m_wrappers.Values + .Where(w => w.ParentKey == null) + .OrderBy(w => w.LeafName, StringComparer.Ordinal)) { - EmitMethodWrapper(ctx.Out, method); + EmitInstanceWrapper(ctx.Out, top, indent: string.Empty); } return null; @@ -316,7 +320,7 @@ private void CollectInstanceWrappers(InstanceDesign root) } /// - /// Registers an for the supplied + /// Registers an InstanceWrapper for the supplied /// node. Idempotent — if the wrapper has already been registered /// (e.g. via a sibling root) the existing entry is reused. /// @@ -332,15 +336,21 @@ private void RegisterInstanceWrapper( return; } - string className = ComposeClassName(root, relativePath, suffix: "Builder"); + string leafName = ResolveLeafName(root, relativePath, hnode.Instance); + string parentKey = ResolveParentKey(root, relativePath, leafName); + string className = ComposeWrapperClassName(leafName, suffix: "Builder"); string nsUri = ResolveNodeBrowseNamespace(hnode.Instance); var wrapper = new InstanceWrapper { Key = key, ClassName = className, + LeafName = leafName, + ParentKey = parentKey, NodeStateType = ResolveStateClrType(hnode.Instance), BrowseNamespaceUri = nsUri, - Children = [] + Children = [], + ChildObjectKeys = [], + ChildMethodKeys = [] }; // Children resolved relative to this node. @@ -356,6 +366,7 @@ private void RegisterInstanceWrapper( string accessorName = GetAccessorName(kid.Instance); string browseName = GetBrowseName(kid.Instance); string browseNsUri = ResolveNodeBrowseNamespace(kid.Instance); + string childLeaf = ResolveLeafName(root, kid.RelativePath, kid.Instance); var child = new ChildAccessor { AccessorName = accessorName, @@ -369,15 +380,21 @@ private void RegisterInstanceWrapper( child.Kind = ChildKind.Variable; child.ValueClrType = GetVariableValueClrType(var); break; - case MethodDesign method: + case MethodDesign: child.Kind = ChildKind.Method; - child.WrapperClassName = ComposeClassName( - root, kid.RelativePath, suffix: "MethodBuilder"); + // Lexical scope: methods are emitted as nested + // classes inside the parent wrapper, so the + // simple leaf name resolves correctly here. + child.WrapperClassName = ComposeWrapperClassName( + childLeaf, suffix: "MethodBuilder"); break; case ObjectDesign: child.Kind = ChildKind.Object; - child.WrapperClassName = ComposeClassName( - root, kid.RelativePath, suffix: "Builder"); + // Lexical scope: object child wrappers are + // nested classes; the simple leaf name resolves + // through the enclosing parent wrapper. + child.WrapperClassName = ComposeWrapperClassName( + childLeaf, suffix: "Builder"); child.ChildKey = childKey; child.ChildStateType = ResolveStateClrType(kid.Instance); break; @@ -393,10 +410,9 @@ private void RegisterInstanceWrapper( } /// - /// Registers a for the supplied method - /// design. Resolves typed argument shapes from - /// / - /// . + /// Registers a MethodWrapper for the supplied method + /// design. Resolves typed argument shapes from the method's + /// InputArguments/OutputArguments. /// private void RegisterMethodWrapper( InstanceDesign root, @@ -409,11 +425,15 @@ private void RegisterMethodWrapper( return; } - string className = ComposeClassName(root, relativePath, suffix: "MethodBuilder"); + string leafName = ResolveLeafName(root, relativePath, method); + string parentKey = ResolveParentKey(root, relativePath, leafName); + string className = ComposeWrapperClassName(leafName, suffix: "MethodBuilder"); var wrapper = new MethodWrapper { Key = key, ClassName = className, + LeafName = leafName, + ParentKey = parentKey, Inputs = method.InputArguments ?? [], Outputs = method.OutputArguments ?? [] }; @@ -424,6 +444,46 @@ private void RegisterMethodWrapper( // Validation // ============================================================ + /// + /// Wires each wrapper to its direct child object/method wrappers + /// so the recursive emitter can walk the tree depth-first. Sorts + /// siblings by leaf name (ordinal) so generation is deterministic. + /// + private void LinkChildWrappers() + { + foreach (InstanceWrapper child in m_wrappers.Values) + { + if (child.ParentKey == null) + { + continue; + } + if (m_wrappers.TryGetValue(child.ParentKey, out InstanceWrapper parent)) + { + parent.ChildObjectKeys.Add(child.Key); + } + } + foreach (MethodWrapper method in m_methodWrappers.Values) + { + if (method.ParentKey == null) + { + continue; + } + if (m_wrappers.TryGetValue(method.ParentKey, out InstanceWrapper parent)) + { + parent.ChildMethodKeys.Add(method.Key); + } + } + foreach (InstanceWrapper wrapper in m_wrappers.Values) + { + wrapper.ChildObjectKeys.Sort((a, b) => string.CompareOrdinal( + m_wrappers[a].LeafName, + m_wrappers[b].LeafName)); + wrapper.ChildMethodKeys.Sort((a, b) => string.CompareOrdinal( + m_methodWrappers[a].LeafName, + m_methodWrappers[b].LeafName)); + } + } + /// /// Verifies that no two children of the same wrapper sanitize to /// the same C# accessor identifier. @@ -672,143 +732,200 @@ private static void EmitPassThroughGenericMethod( /// /// Emits one wrapper class for an - /// describing a non-method instance. + /// describing a non-method instance. The class is rendered at the + /// indentation depth supplied by ; child + /// object/method wrappers are emitted recursively as nested types + /// one level deeper. /// - private void EmitInstanceWrapper(ITemplateWriter writer, InstanceWrapper wrapper) + private void EmitInstanceWrapper( + ITemplateWriter writer, + InstanceWrapper wrapper, + string indent) { + string memberIndent = indent + Indent; + writer.WriteLine(); - writer.WriteLine("/// Typed wrapper for the predefined instance."); + writer.WriteLine("{0}/// Typed wrapper for the predefined instance.", indent); writer.WriteLine( - "[global::System.CodeDom.Compiler.GeneratedCodeAttribute(\"{0}\", \"{1}\")]", + "{0}[global::System.CodeDom.Compiler.GeneratedCodeAttribute(\"{1}\", \"{2}\")]", + indent, ToolName, ToolVersion); - writer.WriteLine("internal sealed class {0}", wrapper.ClassName); - writer.WriteLine("{"); - writer.WriteLine(" private readonly global::Opc.Ua.Server.Fluent.INodeBuilder<{0}> __node;", - wrapper.NodeStateType); + writer.WriteLine("{0}internal sealed class {1}", indent, wrapper.ClassName); + writer.WriteLine("{0}{{", indent); + writer.WriteLine("{0}private readonly global::Opc.Ua.Server.Fluent.INodeBuilder<{1}> __node;", + memberIndent, wrapper.NodeStateType); writer.WriteLine(); - writer.WriteLine(" internal {0}(global::Opc.Ua.Server.Fluent.INodeBuilder<{1}> node)", - wrapper.ClassName, wrapper.NodeStateType); - writer.WriteLine(" {"); - writer.WriteLine(" __node = node ?? throw new global::System.ArgumentNullException(nameof(node));"); - writer.WriteLine(" }"); + writer.WriteLine("{0}internal {1}(global::Opc.Ua.Server.Fluent.INodeBuilder<{2}> node)", + memberIndent, wrapper.ClassName, wrapper.NodeStateType); + writer.WriteLine("{0}{{", memberIndent); + writer.WriteLine("{0}__node = node ?? throw new global::System.ArgumentNullException(nameof(node));", + memberIndent + Indent); + writer.WriteLine("{0}}}", memberIndent); writer.WriteLine(); - writer.WriteLine(" /// Underlying typed node builder."); - writer.WriteLine(" public global::Opc.Ua.Server.Fluent.INodeBuilder<{0}> Builder => __node;", - wrapper.NodeStateType); + writer.WriteLine("{0}/// Underlying typed node builder.", memberIndent); + writer.WriteLine("{0}public global::Opc.Ua.Server.Fluent.INodeBuilder<{1}> Builder => __node;", + memberIndent, wrapper.NodeStateType); writer.WriteLine(); - writer.WriteLine(" /// Resolved underlying node."); - writer.WriteLine(" public {0} Node => __node.Node;", wrapper.NodeStateType); + writer.WriteLine("{0}/// Resolved underlying node.", memberIndent); + writer.WriteLine("{0}public {1} Node => __node.Node;", memberIndent, wrapper.NodeStateType); foreach (ChildAccessor child in wrapper.Children) { - EmitChildAccessor(writer, child); + EmitChildAccessor(writer, child, memberIndent); } - writer.WriteLine("}"); + // Emit the nested method wrappers, then the nested object + // wrappers. Sibling order is leaf-name ordinal (set up by + // LinkChildWrappers) so generation is deterministic. + foreach (string methodKey in wrapper.ChildMethodKeys) + { + if (m_methodWrappers.TryGetValue(methodKey, out MethodWrapper nestedMethod)) + { + EmitMethodWrapper(writer, nestedMethod, memberIndent); + } + } + foreach (string childKey in wrapper.ChildObjectKeys) + { + if (m_wrappers.TryGetValue(childKey, out InstanceWrapper nested)) + { + EmitInstanceWrapper(writer, nested, memberIndent); + } + } + + writer.WriteLine("{0}}}", indent); } /// - /// Emits one accessor property on the parent wrapper. + /// Emits one accessor property on the parent wrapper at the + /// supplied (the parent's member + /// indent). /// - private void EmitChildAccessor(ITemplateWriter writer, ChildAccessor child) + private void EmitChildAccessor( + ITemplateWriter writer, + ChildAccessor child, + string indent) { + string bodyIndent = indent + Indent; + string innerIndent = bodyIndent + Indent; + writer.WriteLine(); switch (child.Kind) { case ChildKind.Variable: - writer.WriteLine(" /// Typed accessor for variable child {0}.", - child.BrowseName); - writer.WriteLine(" public global::Opc.Ua.Server.Fluent.IVariableBuilder<{0}> {1}", - child.ValueClrType, child.AccessorName); - writer.WriteLine(" {"); - writer.WriteLine(" get"); - writer.WriteLine(" {"); - writer.WriteLine(" ushort __ns = __node.Builder.Context.NamespaceUris.GetIndexOrAppend(\"{0}\");", - EscapeStringLiteral(child.BrowseNamespaceUri)); - writer.WriteLine(" return __node.Variable<{0}>(new global::Opc.Ua.QualifiedName(\"{1}\", __ns));", + writer.WriteLine("{0}/// Typed accessor for variable child {1}.", + indent, child.BrowseName); + writer.WriteLine("{0}public global::Opc.Ua.Server.Fluent.IVariableBuilder<{1}> {2}", + indent, child.ValueClrType, child.AccessorName); + writer.WriteLine("{0}{{", indent); + writer.WriteLine("{0}get", bodyIndent); + writer.WriteLine("{0}{{", bodyIndent); + writer.WriteLine("{0}ushort __ns = __node.Builder.Context.NamespaceUris.GetIndexOrAppend(\"{1}\");", + innerIndent, EscapeStringLiteral(child.BrowseNamespaceUri)); + writer.WriteLine("{0}return __node.Variable<{1}>(new global::Opc.Ua.QualifiedName(\"{2}\", __ns));", + innerIndent, child.ValueClrType, EscapeStringLiteral(child.BrowseName)); - writer.WriteLine(" }"); - writer.WriteLine(" }"); + writer.WriteLine("{0}}}", bodyIndent); + writer.WriteLine("{0}}}", indent); break; case ChildKind.Method: - writer.WriteLine(" /// Typed accessor for method child {0}.", - child.BrowseName); - writer.WriteLine(" public {0} {1}", child.WrapperClassName, child.AccessorName); - writer.WriteLine(" {"); - writer.WriteLine(" get"); - writer.WriteLine(" {"); - writer.WriteLine(" ushort __ns = __node.Builder.Context.NamespaceUris.GetIndexOrAppend(\"{0}\");", - EscapeStringLiteral(child.BrowseNamespaceUri)); - writer.WriteLine(" return new {0}(__node.Child(new global::Opc.Ua.QualifiedName(\"{1}\", __ns)));", + writer.WriteLine("{0}/// Typed accessor for method child {1}.", + indent, child.BrowseName); + writer.WriteLine("{0}public {1} {2}", indent, child.WrapperClassName, child.AccessorName); + writer.WriteLine("{0}{{", indent); + writer.WriteLine("{0}get", bodyIndent); + writer.WriteLine("{0}{{", bodyIndent); + writer.WriteLine("{0}ushort __ns = __node.Builder.Context.NamespaceUris.GetIndexOrAppend(\"{1}\");", + innerIndent, EscapeStringLiteral(child.BrowseNamespaceUri)); + writer.WriteLine("{0}return new {1}(__node.Child(new global::Opc.Ua.QualifiedName(\"{2}\", __ns)));", + innerIndent, child.WrapperClassName, EscapeStringLiteral(child.BrowseName)); - writer.WriteLine(" }"); - writer.WriteLine(" }"); + writer.WriteLine("{0}}}", bodyIndent); + writer.WriteLine("{0}}}", indent); break; case ChildKind.Object: - writer.WriteLine(" /// Typed accessor for object child {0}.", - child.BrowseName); - writer.WriteLine(" public {0} {1}", child.WrapperClassName, child.AccessorName); - writer.WriteLine(" {"); - writer.WriteLine(" get"); - writer.WriteLine(" {"); - writer.WriteLine(" ushort __ns = __node.Builder.Context.NamespaceUris.GetIndexOrAppend(\"{0}\");", - EscapeStringLiteral(child.BrowseNamespaceUri)); - writer.WriteLine(" return new {0}(__node.Child<{1}>(new global::Opc.Ua.QualifiedName(\"{2}\", __ns)));", + writer.WriteLine("{0}/// Typed accessor for object child {1}.", + indent, child.BrowseName); + writer.WriteLine("{0}public {1} {2}", indent, child.WrapperClassName, child.AccessorName); + writer.WriteLine("{0}{{", indent); + writer.WriteLine("{0}get", bodyIndent); + writer.WriteLine("{0}{{", bodyIndent); + writer.WriteLine("{0}ushort __ns = __node.Builder.Context.NamespaceUris.GetIndexOrAppend(\"{1}\");", + innerIndent, EscapeStringLiteral(child.BrowseNamespaceUri)); + writer.WriteLine("{0}return new {1}(__node.Child<{2}>(new global::Opc.Ua.QualifiedName(\"{3}\", __ns)));", + innerIndent, child.WrapperClassName, child.ChildStateType, EscapeStringLiteral(child.BrowseName)); - writer.WriteLine(" }"); - writer.WriteLine(" }"); + writer.WriteLine("{0}}}", bodyIndent); + writer.WriteLine("{0}}}", indent); break; } } /// /// Emits one wrapper class for a method instance with typed - /// OnCall overloads. + /// OnCall overloads at the supplied . /// - private void EmitMethodWrapper(ITemplateWriter writer, MethodWrapper method) + private void EmitMethodWrapper( + ITemplateWriter writer, + MethodWrapper method, + string indent) { + string memberIndent = indent + Indent; + writer.WriteLine(); - writer.WriteLine("/// Typed method-call wrapper for the predefined method."); + writer.WriteLine("{0}/// Typed method-call wrapper for the predefined method.", indent); writer.WriteLine( - "[global::System.CodeDom.Compiler.GeneratedCodeAttribute(\"{0}\", \"{1}\")]", + "{0}[global::System.CodeDom.Compiler.GeneratedCodeAttribute(\"{1}\", \"{2}\")]", + indent, ToolName, ToolVersion); - writer.WriteLine("internal sealed class {0}", method.ClassName); - writer.WriteLine("{"); - writer.WriteLine(" private readonly global::Opc.Ua.Server.Fluent.INodeBuilder __node;"); + writer.WriteLine("{0}internal sealed class {1}", indent, method.ClassName); + writer.WriteLine("{0}{{", indent); + writer.WriteLine("{0}private readonly global::Opc.Ua.Server.Fluent.INodeBuilder __node;", + memberIndent); writer.WriteLine(); - writer.WriteLine(" internal {0}(global::Opc.Ua.Server.Fluent.INodeBuilder node)", - method.ClassName); - writer.WriteLine(" {"); - writer.WriteLine(" __node = node ?? throw new global::System.ArgumentNullException(nameof(node));"); - writer.WriteLine(" }"); + writer.WriteLine("{0}internal {1}(global::Opc.Ua.Server.Fluent.INodeBuilder node)", + memberIndent, method.ClassName); + writer.WriteLine("{0}{{", memberIndent); + writer.WriteLine("{0}__node = node ?? throw new global::System.ArgumentNullException(nameof(node));", + memberIndent + Indent); + writer.WriteLine("{0}}}", memberIndent); writer.WriteLine(); - writer.WriteLine(" /// Underlying typed node builder. Use to drop into the non-typed fluent surface."); - writer.WriteLine(" public global::Opc.Ua.Server.Fluent.INodeBuilder Builder => __node;"); + writer.WriteLine("{0}/// Underlying typed node builder. Use to drop into the non-typed fluent surface.", + memberIndent); + writer.WriteLine("{0}public global::Opc.Ua.Server.Fluent.INodeBuilder Builder => __node;", + memberIndent); writer.WriteLine(); - writer.WriteLine(" /// Resolved underlying method state."); - writer.WriteLine(" public global::Opc.Ua.MethodState Node => __node.Node;"); + writer.WriteLine("{0}/// Resolved underlying method state.", memberIndent); + writer.WriteLine("{0}public global::Opc.Ua.MethodState Node => __node.Node;", memberIndent); // Sync typed OnCall. - EmitMethodOnCall(writer, method, async: false); + EmitMethodOnCall(writer, method, async: false, indent: memberIndent); // Async typed OnCall. - EmitMethodOnCall(writer, method, async: true); + EmitMethodOnCall(writer, method, async: true, indent: memberIndent); - writer.WriteLine("}"); + writer.WriteLine("{0}}}", indent); } /// /// Emits one OnCall overload for a method wrapper. The overload /// shape is determined by the method's argument signature plus the - /// requested sync/async flavor. + /// requested sync/async flavor. is the + /// member indent of the enclosing method wrapper. /// - private void EmitMethodOnCall(ITemplateWriter writer, MethodWrapper method, bool async) + private void EmitMethodOnCall( + ITemplateWriter writer, + MethodWrapper method, + bool async, + string indent) { + string bodyIndent = indent + Indent; + string lambdaIndent = bodyIndent + Indent; + string targetNamespace = m_context.ModelDesign.TargetNamespace.Value; Namespace[] namespaces = m_context.ModelDesign.Namespaces; Parameter[] inputs = method.Inputs; @@ -860,46 +977,48 @@ private void EmitMethodOnCall(ITemplateWriter writer, MethodWrapper method, bool } writer.WriteLine(); - writer.WriteLine(" /// Wires the method-call handler ({0}).", - async ? "async" : "sync"); - writer.WriteLine(" public {0} OnCall({1} handler)", method.ClassName, handlerType); - writer.WriteLine(" {"); - writer.WriteLine(" if (handler == null) throw new global::System.ArgumentNullException(nameof(handler));"); + writer.WriteLine("{0}/// Wires the method-call handler ({1}).", + indent, async ? "async" : "sync"); + writer.WriteLine("{0}public {1} OnCall({2} handler)", indent, method.ClassName, handlerType); + writer.WriteLine("{0}{{", indent); + writer.WriteLine("{0}if (handler == null) throw new global::System.ArgumentNullException(nameof(handler));", + bodyIndent); if (async) { - writer.WriteLine(" __node.OnCall(async ("); - writer.WriteLine(" global::Opc.Ua.ISystemContext __ctx,"); - writer.WriteLine(" global::Opc.Ua.MethodState __m,"); - writer.WriteLine(" global::Opc.Ua.NodeId __oid,"); - writer.WriteLine(" global::Opc.Ua.ArrayOf __inputs,"); - writer.WriteLine(" global::System.Collections.Generic.List __outputs,"); - writer.WriteLine(" global::System.Threading.CancellationToken __ct) =>"); - writer.WriteLine(" {"); + writer.WriteLine("{0}__node.OnCall(async (", bodyIndent); + writer.WriteLine("{0}global::Opc.Ua.ISystemContext __ctx,", lambdaIndent); + writer.WriteLine("{0}global::Opc.Ua.MethodState __m,", lambdaIndent); + writer.WriteLine("{0}global::Opc.Ua.NodeId __oid,", lambdaIndent); + writer.WriteLine("{0}global::Opc.Ua.ArrayOf __inputs,", lambdaIndent); + writer.WriteLine("{0}global::System.Collections.Generic.List __outputs,", lambdaIndent); + writer.WriteLine("{0}global::System.Threading.CancellationToken __ct) =>", lambdaIndent); + writer.WriteLine("{0}{{", bodyIndent); } else { - writer.WriteLine(" __node.OnCall(("); - writer.WriteLine(" global::Opc.Ua.ISystemContext __ctx,"); - writer.WriteLine(" global::Opc.Ua.MethodState __m,"); - writer.WriteLine(" global::Opc.Ua.NodeId __oid,"); - writer.WriteLine(" global::Opc.Ua.ArrayOf __inputs,"); - writer.WriteLine(" global::System.Collections.Generic.List __outputs) =>"); - writer.WriteLine(" {"); + writer.WriteLine("{0}__node.OnCall((", bodyIndent); + writer.WriteLine("{0}global::Opc.Ua.ISystemContext __ctx,", lambdaIndent); + writer.WriteLine("{0}global::Opc.Ua.MethodState __m,", lambdaIndent); + writer.WriteLine("{0}global::Opc.Ua.NodeId __oid,", lambdaIndent); + writer.WriteLine("{0}global::Opc.Ua.ArrayOf __inputs,", lambdaIndent); + writer.WriteLine("{0}global::System.Collections.Generic.List __outputs) =>", lambdaIndent); + writer.WriteLine("{0}{{", bodyIndent); } // Validate input arg count. if (inputs.Length > 0) { - writer.WriteLine(" if (__inputs.Count < {0})", inputs.Length); - writer.WriteLine(" {"); - writer.WriteLine(" return new global::Opc.Ua.ServiceResult(global::Opc.Ua.StatusCodes.BadArgumentsMissing);"); - writer.WriteLine(" }"); + writer.WriteLine("{0}if (__inputs.Count < {1})", lambdaIndent, inputs.Length); + writer.WriteLine("{0}{{", lambdaIndent); + writer.WriteLine("{0}return new global::Opc.Ua.ServiceResult(global::Opc.Ua.StatusCodes.BadArgumentsMissing);", + lambdaIndent + Indent); + writer.WriteLine("{0}}}", lambdaIndent); } // Unpack inputs. for (int ii = 0; ii < inputs.Length; ii++) { - EmitInputUnpack(writer, inputs[ii], ii, targetNamespace, namespaces); + EmitInputUnpack(writer, inputs[ii], ii, targetNamespace, namespaces, lambdaIndent); } // Invoke user handler. @@ -907,19 +1026,13 @@ private void EmitMethodOnCall(ITemplateWriter writer, MethodWrapper method, bool { if (outputs.Length == 0) { - writer.Write(" await handler("); - EmitInputArgPassThrough(writer, inputs, withCt: true); - writer.WriteLine(").ConfigureAwait(false);"); - } - else if (outputs.Length == 1) - { - writer.Write(" var __r = await handler("); + writer.Write("{0}await handler(", lambdaIndent); EmitInputArgPassThrough(writer, inputs, withCt: true); writer.WriteLine(").ConfigureAwait(false);"); } else { - writer.Write(" var __r = await handler("); + writer.Write("{0}var __r = await handler(", lambdaIndent); EmitInputArgPassThrough(writer, inputs, withCt: true); writer.WriteLine(").ConfigureAwait(false);"); } @@ -928,13 +1041,13 @@ private void EmitMethodOnCall(ITemplateWriter writer, MethodWrapper method, bool { if (outputs.Length == 0) { - writer.Write(" handler("); + writer.Write("{0}handler(", lambdaIndent); EmitInputArgPassThrough(writer, inputs, withCt: false); writer.WriteLine(");"); } else { - writer.Write(" var __r = handler("); + writer.Write("{0}var __r = handler(", lambdaIndent); EmitInputArgPassThrough(writer, inputs, withCt: false); writer.WriteLine(");"); } @@ -943,33 +1056,29 @@ private void EmitMethodOnCall(ITemplateWriter writer, MethodWrapper method, bool // Marshal outputs. for (int ii = 0; ii < outputs.Length; ii++) { - EmitOutputBox(writer, outputs[ii], ii, outputs.Length); + EmitOutputBox(writer, outputs[ii], ii, outputs.Length, lambdaIndent); } - writer.WriteLine(" return global::Opc.Ua.ServiceResult.Good;"); - if (async) - { - writer.WriteLine(" });"); - } - else - { - writer.WriteLine(" });"); - } - writer.WriteLine(" return this;"); - writer.WriteLine(" }"); + writer.WriteLine("{0}return global::Opc.Ua.ServiceResult.Good;", lambdaIndent); + writer.WriteLine("{0}}});", bodyIndent); + writer.WriteLine("{0}return this;", bodyIndent); + writer.WriteLine("{0}}}", indent); } /// /// Emits the typed unpack code for a single input argument. Mirrors - /// the logic in . + /// the logic in ObjectTypeProxyGenerator. + /// is the lambda-body indent of the surrounding OnCall. /// private static void EmitInputUnpack( ITemplateWriter writer, Parameter input, int index, string targetNamespace, - Namespace[] namespaces) + Namespace[] namespaces, + string indent) { + string innerIndent = indent + Indent; string typeName = input.DataTypeNode.GetMethodArgumentTypeAsCode( input.ValueRank, targetNamespace, @@ -979,21 +1088,23 @@ private static void EmitInputUnpack( switch (input.DataTypeNode.BasicDataType) { case BasicDataType.UserDefined: - writer.WriteLine(" if (!__inputs[{0}].TryGetStructure(out {1} {2}))", - index, typeName, local); - writer.WriteLine(" {"); - writer.WriteLine(" return new global::Opc.Ua.ServiceResult(global::Opc.Ua.StatusCodes.BadInvalidArgument);"); - writer.WriteLine(" }"); + writer.WriteLine("{0}if (!__inputs[{1}].TryGetStructure(out {2} {3}))", + indent, index, typeName, local); + writer.WriteLine("{0}{{", indent); + writer.WriteLine("{0}return new global::Opc.Ua.ServiceResult(global::Opc.Ua.StatusCodes.BadInvalidArgument);", + innerIndent); + writer.WriteLine("{0}}}", indent); break; case BasicDataType.BaseDataType when input.ValueRank == ValueRank.Scalar: - writer.WriteLine(" {0} {1} = __inputs[{2}];", typeName, local, index); + writer.WriteLine("{0}{1} {2} = __inputs[{3}];", indent, typeName, local, index); break; default: - writer.WriteLine(" if (!__inputs[{0}].TryGetValue(out {1} {2}))", - index, typeName, local); - writer.WriteLine(" {"); - writer.WriteLine(" return new global::Opc.Ua.ServiceResult(global::Opc.Ua.StatusCodes.BadInvalidArgument);"); - writer.WriteLine(" }"); + writer.WriteLine("{0}if (!__inputs[{1}].TryGetValue(out {2} {3}))", + indent, index, typeName, local); + writer.WriteLine("{0}{{", indent); + writer.WriteLine("{0}return new global::Opc.Ua.ServiceResult(global::Opc.Ua.StatusCodes.BadInvalidArgument);", + innerIndent); + writer.WriteLine("{0}}}", indent); break; } } @@ -1029,12 +1140,15 @@ private static void EmitInputArgPassThrough( /// Emits the boxing code for a single output argument. For multi- /// output methods the user returns a ValueTuple and we /// destructure by field name (Item1, Item2, …). + /// is the lambda-body indent of the + /// surrounding OnCall. /// private static void EmitOutputBox( ITemplateWriter writer, Parameter output, int index, - int totalOutputs) + int totalOutputs, + string indent) { string source; if (totalOutputs == 1) @@ -1049,13 +1163,13 @@ private static void EmitOutputBox( switch (output.DataTypeNode.BasicDataType) { case BasicDataType.UserDefined: - writer.WriteLine(" __outputs.Add(global::Opc.Ua.Variant.FromStructure({0}));", source); + writer.WriteLine("{0}__outputs.Add(global::Opc.Ua.Variant.FromStructure({1}));", indent, source); break; case BasicDataType.BaseDataType when output.ValueRank == ValueRank.Scalar: - writer.WriteLine(" __outputs.Add({0});", source); + writer.WriteLine("{0}__outputs.Add({1});", indent, source); break; default: - writer.WriteLine(" __outputs.Add(global::Opc.Ua.Variant.From({0}));", source); + writer.WriteLine("{0}__outputs.Add(global::Opc.Ua.Variant.From({1}));", indent, source); break; } } @@ -1153,13 +1267,70 @@ private static string ComposeKey(InstanceDesign root, string relativePath) return rootId + "_" + relativePath; } - private static string ComposeClassName( + /// + /// Returns the simple leaf name (the last segment of the relative + /// path) used as the C# class name's stem. Honors the convention + /// that segment names themselves can contain underscores so we + /// rely on the instance's SymbolicName.Name rather than + /// splitting on NodeDesign.PathChar. For the root + /// instance (empty ) the leaf is + /// the root's own SymbolicId.Name. + /// + private static string ResolveLeafName( InstanceDesign root, string relativePath, - string suffix) + NodeDesign instance) { - string key = ComposeKey(root, relativePath); - return key + suffix; + if (string.IsNullOrEmpty(relativePath)) + { + return root?.SymbolicId?.Name ?? string.Empty; + } + string symbolicName = instance?.SymbolicName?.Name; + if (string.IsNullOrEmpty(symbolicName)) + { + return relativePath; + } + return symbolicName; + } + + /// + /// Returns the wrapper key of the lexical parent for the wrapper + /// at under . + /// Returns null for the root itself (lives at namespace + /// scope), the root's key for direct children, and the parent + /// path's key for deeper nesting. + /// + private static string ResolveParentKey( + InstanceDesign root, + string relativePath, + string leafName) + { + if (string.IsNullOrEmpty(relativePath)) + { + return null; + } + if (relativePath.Length == leafName.Length) + { + return ComposeKey(root, string.Empty); + } + int trim = leafName.Length + 1; + if (relativePath.Length <= trim) + { + return ComposeKey(root, string.Empty); + } + string parentPath = relativePath[..^trim]; + return ComposeKey(root, parentPath); + } + + /// + /// Returns the wrapper's CLR class name. Wrappers are emitted as + /// nested types so the simple leaf name is sufficient — full + /// dotted access is composed by the consumer through the chain + /// of typed accessor properties. + /// + private static string ComposeWrapperClassName(string leafName, string suffix) + { + return (leafName ?? string.Empty) + suffix; } private static string GetAccessorName(NodeDesign node) @@ -1274,6 +1445,11 @@ private static string EscapeStringLiteral(string value) private Dictionary m_wrappers = []; private Dictionary m_methodWrappers = []; + // Single nesting step. Wrappers are emitted inside the body of + // the file template at column 4; each additional nesting level + // adds one Indent. + private const string Indent = " "; + private static string ToolName => System.Reflection.Assembly.GetExecutingAssembly().GetName().Name; private static string ToolVersion @@ -1290,9 +1466,13 @@ private sealed class InstanceWrapper { public string Key; public string ClassName; + public string LeafName; + public string ParentKey; public string NodeStateType; public string BrowseNamespaceUri; public List Children; + public List ChildObjectKeys = []; + public List ChildMethodKeys = []; } private sealed class ChildAccessor @@ -1311,6 +1491,8 @@ private sealed class MethodWrapper { public string Key; public string ClassName; + public string LeafName; + public string ParentKey; public Parameter[] Inputs; public Parameter[] Outputs; } From 0c2bc3fc4e47fb89233fea8efc53e554b8dbc67f Mon Sep 17 00:00:00 2001 From: Marc Schier Date: Wed, 13 May 2026 09:56:36 +0200 Subject: [PATCH 12/19] Add snapshot tests for typed OnCall with method arguments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new MathInstance predefined Object instance to TestModel.xml that hosts two methods declared with inline InputArguments and OutputArguments — Compute(x: Int32 -> result: Int32) and Add(a: Int32, b: Int32 -> sum: Int32). This finally exercises the FluentBuilderGenerator code path that emits typed OnCall(...) overloads (EmitInputUnpack / EmitOutputBox / FormatInputTypeList), which previously had zero snapshot coverage because the test model contained no predefined method instance with arguments. Adds five FluentBuilderGenerator snapshot tests asserting: * MethodWithIntInputAndOutputEmitsTypedSyncOverload — sync OnCall(Func) is emitted on ComputeMethodBuilder. * MethodWithIntInputAndOutputEmitsTypedAsyncOverload — async OnCall(Func>) is emitted. * MethodInputUnpackUsesVariantTryGetValue — generated lambda body unpacks Variants via TryGetValue and short-circuits with BadInvalidArgument / BadArgumentsMissing as appropriate. * MethodOutputBoxUsesVariantFrom — single-output methods box the handler return value via Variant.From(__r). * MethodWithMultipleInputArgsEmitsCorrectArity — multi-input methods (Add) produce Func sync and Func> async overloads, and unpack each Variant by index. FluentBuilderGenerator tests grow from 12 to 17; full source-gen suite passes 3511/0/8 across net8/9/10/472/48 (was 3506/0/8). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Generators/FluentBuilderGeneratorTests.cs | 80 +++++++++++++++++++ .../Resources/TestModel.xml | 22 +++++ 2 files changed, 102 insertions(+) diff --git a/Tests/Opc.Ua.SourceGeneration.Core.Tests/Generators/FluentBuilderGeneratorTests.cs b/Tests/Opc.Ua.SourceGeneration.Core.Tests/Generators/FluentBuilderGeneratorTests.cs index 9072c8e9f8..80d031b467 100644 --- a/Tests/Opc.Ua.SourceGeneration.Core.Tests/Generators/FluentBuilderGeneratorTests.cs +++ b/Tests/Opc.Ua.SourceGeneration.Core.Tests/Generators/FluentBuilderGeneratorTests.cs @@ -206,6 +206,86 @@ public void EmittedFluentBuilders_ChildAccessorReturnsSimpleLeafName() "Child accessor for 'Blue' should return the simple-leaf-name 'BlueMethodBuilder'"); } + [Test] + public void MethodWithIntInputAndOutputEmitsTypedSyncOverload() + { + string fb = GetFluentBuilders(); + + // TestModel's MathInstance has a Mandatory method 'Compute' + // declared inline with InputArguments(x: Int32) and + // OutputArguments(result: Int32). The generator must surface + // a typed sync OnCall overload that maps Variant<-> int both + // ways without requiring the user to touch ArrayOf. + Assert.That(fb, Does.Match( + @"public\s+ComputeMethodBuilder\s+OnCall\s*\(\s*global::System\.Func\s+handler\s*\)"), + "Compute(Int32 -> Int32) should expose a sync OnCall(Func) overload"); + } + + [Test] + public void MethodWithIntInputAndOutputEmitsTypedAsyncOverload() + { + string fb = GetFluentBuilders(); + + // The async overload threads CancellationToken between the + // unpacked inputs and the ValueTask return type. + Assert.That(fb, Does.Match( + @"public\s+ComputeMethodBuilder\s+OnCall\s*\(\s*global::System\.Func>\s+handler\s*\)"), + "Compute(Int32 -> Int32) should expose an async OnCall(Func>) overload"); + } + + [Test] + public void MethodInputUnpackUsesVariantTryGetValue() + { + string fb = GetFluentBuilders(); + + // Inputs flow through Variant.TryGetValue(out T) so the + // generated lambda short-circuits with BadInvalidArgument when + // the caller sends the wrong wire type. + Assert.That(fb, Does.Match( + @"if\s*\(!__inputs\[0\]\.TryGetValue\(out\s+int\s+__a0\)\)"), + "Input unpack should use Variant.TryGetValue(out T) for primitive inputs"); + Assert.That(fb, Does.Contain( + "return new global::Opc.Ua.ServiceResult(global::Opc.Ua.StatusCodes.BadInvalidArgument);"), + "Failed input unpack should return BadInvalidArgument"); + Assert.That(fb, Does.Contain( + "return new global::Opc.Ua.ServiceResult(global::Opc.Ua.StatusCodes.BadArgumentsMissing);"), + "Missing input arg count should return BadArgumentsMissing"); + } + + [Test] + public void MethodOutputBoxUsesVariantFrom() + { + string fb = GetFluentBuilders(); + + // Outputs are boxed back through Variant.From(value) and + // appended to the __outputs list. Single-output methods bind + // the user handler's return value to '__r'. + Assert.That(fb, Does.Contain( + "__outputs.Add(global::Opc.Ua.Variant.From(__r));"), + "Single-output methods should box the handler result via Variant.From(__r)"); + } + + [Test] + public void MethodWithMultipleInputArgsEmitsCorrectArity() + { + string fb = GetFluentBuilders(); + + // TestModel's MathInstance.Add(a: Int32, b: Int32 -> sum: Int32) + // must produce a sync OnCall overload whose handler arity + // matches the input count (Func) and an async + // overload that appends CancellationToken + ValueTask. + Assert.That(fb, Does.Match( + @"public\s+AddMethodBuilder\s+OnCall\s*\(\s*global::System\.Func\s+handler\s*\)"), + "Add(Int32,Int32 -> Int32) should expose a sync OnCall(Func) overload"); + Assert.That(fb, Does.Match( + @"public\s+AddMethodBuilder\s+OnCall\s*\(\s*global::System\.Func>\s+handler\s*\)"), + "Add(Int32,Int32 -> Int32) should expose an async OnCall(Func>) overload"); + // Unpack arity: index 0 and index 1 must both be present. + Assert.That(fb, Does.Match( + @"if\s*\(!__inputs\[1\]\.TryGetValue\(out\s+int\s+__a1\)\)"), + "Multi-input methods should unpack each Variant by index (e.g. __inputs[1] -> __a1)"); + } + [Test] public void EmittedNodeManager_InvokesBothPlainAndTypedConfigurePartials() { diff --git a/Tests/Opc.Ua.SourceGeneration.Core.Tests/Resources/TestModel.xml b/Tests/Opc.Ua.SourceGeneration.Core.Tests/Resources/TestModel.xml index bc8a80784d..6e3847f32b 100644 --- a/Tests/Opc.Ua.SourceGeneration.Core.Tests/Resources/TestModel.xml +++ b/Tests/Opc.Ua.SourceGeneration.Core.Tests/Resources/TestModel.xml @@ -324,6 +324,28 @@ + + + + + + + + + + + + + + + + + + + + + + From 45b3d089d79b3a11da7630a4e0127d01ef2f7191 Mon Sep 17 00:00:00 2001 From: Marc Schier Date: Wed, 13 May 2026 11:03:31 +0200 Subject: [PATCH 13/19] Add MinimalCalcServer sample with typed method-with-args wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an end-to-end sample exercising the typed source-generated `OnCall` overloads on methods with input/output arguments — sync `int+int -> int`, async `double+double -> double`, and sync `string+string -> string` — plus AOT round-trip tests covering each shape via `Session.CallAsync`. Fixes a runtime bug in `FluentBuilderGenerator.EmitOutputBox` discovered by the new tests: the typed wrapper appended boxed return values to the outputs list with `__outputs.Add(...)` while the base `MethodState.Call` dispatcher pre-populates the list with default `Variant` slots — one per declared output argument — before invoking the user handler. The result was double-counted outputs at the wire. The wrapper now assigns by index (`__outputs[i] = ...`); the matching unit assertion in `FluentBuilderGeneratorTests` is updated. The sample model uses the proven `ModelDesign` pattern (top-level `MethodType` declarations + `CalculatorType` `ObjectType` + `Calculator` predefined instance with an explicit inverse `Organizes` reference back to `ObjectsFolder`) so the typed `MethodState` subclasses, fluent builders and a published instance all reach the address space. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../CalcNodeManager.Configure.cs | 96 +++++ .../MinimalCalcServer.csproj | 37 ++ Applications/MinimalCalcServer/Model/Calc.xml | 114 ++++++ Applications/MinimalCalcServer/Program.cs | 52 +++ .../Properties/AssemblyInfo.cs | 32 ++ .../CalculatorNodeManagerAotTests.cs | 361 ++++++++++++++++++ .../Opc.Ua.Aot.Tests/Opc.Ua.Aot.Tests.csproj | 3 + .../Generators/FluentBuilderGeneratorTests.cs | 10 +- .../Generators/FluentBuilderGenerator.cs | 16 +- UA.slnx | 1 + 10 files changed, 715 insertions(+), 7 deletions(-) create mode 100644 Applications/MinimalCalcServer/CalcNodeManager.Configure.cs create mode 100644 Applications/MinimalCalcServer/MinimalCalcServer.csproj create mode 100644 Applications/MinimalCalcServer/Model/Calc.xml create mode 100644 Applications/MinimalCalcServer/Program.cs create mode 100644 Applications/MinimalCalcServer/Properties/AssemblyInfo.cs create mode 100644 Tests/Opc.Ua.Aot.Tests/CalculatorNodeManagerAotTests.cs 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/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.SourceGeneration.Core.Tests/Generators/FluentBuilderGeneratorTests.cs b/Tests/Opc.Ua.SourceGeneration.Core.Tests/Generators/FluentBuilderGeneratorTests.cs index 80d031b467..4759759743 100644 --- a/Tests/Opc.Ua.SourceGeneration.Core.Tests/Generators/FluentBuilderGeneratorTests.cs +++ b/Tests/Opc.Ua.SourceGeneration.Core.Tests/Generators/FluentBuilderGeneratorTests.cs @@ -258,11 +258,13 @@ public void MethodOutputBoxUsesVariantFrom() string fb = GetFluentBuilders(); // Outputs are boxed back through Variant.From(value) and - // appended to the __outputs list. Single-output methods bind - // the user handler's return value to '__r'. + // assigned by index into the __outputs list which the base + // MethodState dispatcher pre-populates with default values + // (one slot per declared output argument). Single-output + // methods bind the user handler's return value to '__r'. Assert.That(fb, Does.Contain( - "__outputs.Add(global::Opc.Ua.Variant.From(__r));"), - "Single-output methods should box the handler result via Variant.From(__r)"); + "__outputs[0] = global::Opc.Ua.Variant.From(__r);"), + "Single-output methods should box the handler result via __outputs[0] = Variant.From(__r)"); } [Test] diff --git a/Tools/Opc.Ua.SourceGeneration.Core/Generators/FluentBuilderGenerator.cs b/Tools/Opc.Ua.SourceGeneration.Core/Generators/FluentBuilderGenerator.cs index 1dc578c7db..f59f249308 100644 --- a/Tools/Opc.Ua.SourceGeneration.Core/Generators/FluentBuilderGenerator.cs +++ b/Tools/Opc.Ua.SourceGeneration.Core/Generators/FluentBuilderGenerator.cs @@ -1143,6 +1143,14 @@ private static void EmitInputArgPassThrough( /// is the lambda-body indent of the /// surrounding OnCall. /// + /// + /// The base dispatcher + /// pre-populates the outputs list with one default-valued + /// per declared output argument + /// before invoking the user handler, so the wrapper assigns + /// boxed values by index rather than appending to avoid + /// double-counting outputs at the wire. + /// private static void EmitOutputBox( ITemplateWriter writer, Parameter output, @@ -1160,16 +1168,18 @@ private static void EmitOutputBox( source = "__r.Item" + (index + 1).ToString(System.Globalization.CultureInfo.InvariantCulture); } + string indexLiteral = index.ToString(System.Globalization.CultureInfo.InvariantCulture); + switch (output.DataTypeNode.BasicDataType) { case BasicDataType.UserDefined: - writer.WriteLine("{0}__outputs.Add(global::Opc.Ua.Variant.FromStructure({1}));", indent, source); + writer.WriteLine("{0}__outputs[{1}] = global::Opc.Ua.Variant.FromStructure({2});", indent, indexLiteral, source); break; case BasicDataType.BaseDataType when output.ValueRank == ValueRank.Scalar: - writer.WriteLine("{0}__outputs.Add({1});", indent, source); + writer.WriteLine("{0}__outputs[{1}] = {2};", indent, indexLiteral, source); break; default: - writer.WriteLine("{0}__outputs.Add(global::Opc.Ua.Variant.From({1}));", indent, source); + writer.WriteLine("{0}__outputs[{1}] = global::Opc.Ua.Variant.From({2});", indent, indexLiteral, source); break; } } diff --git a/UA.slnx b/UA.slnx index 399f3a26ad..d54200d9e8 100644 --- a/UA.slnx +++ b/UA.slnx @@ -4,6 +4,7 @@ + From a90e8e0db002ccadda7103acb55ce78a20480cfc Mon Sep 17 00:00:00 2001 From: Marc Schier Date: Wed, 13 May 2026 11:03:41 +0200 Subject: [PATCH 14/19] Document method-with-arguments typed OnCall path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a "Methods with arguments — typed `OnCall` overloads" section to `Docs/SourceGeneratedNodeManagers.md` describing the typed sync and async `OnCall(Func<...>)` overloads, `Variant.TryGetValue` input-unpack semantics, `Variant.From` output-box semantics, and the multi-output `ValueTuple` shape. Updates the "What the generator emits per model" bullet to point at the new section, and lists `Applications/MinimalCalcServer/` alongside the existing Boiler sample with a pointer at the companion AOT round-trip tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Docs/SourceGeneratedNodeManagers.md | 89 ++++++++++++++++++++++++++--- 1 file changed, 81 insertions(+), 8 deletions(-) diff --git a/Docs/SourceGeneratedNodeManagers.md b/Docs/SourceGeneratedNodeManagers.md index 92537b9812..71f8100b1d 100644 --- a/Docs/SourceGeneratedNodeManagers.md +++ b/Docs/SourceGeneratedNodeManagers.md @@ -265,12 +265,14 @@ the generator emits, into a single `{Manager}.FluentBuilders.g.cs`: - 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 sync - `OnCall(Action)` and async - `OnCall(Func)` overloads. (Method - overloads with input/output arguments are unboxed and re-boxed by - the generator using the same `Variant.TryGetValue` / - `Variant.From` pattern as the client-side `[ObjectType]` proxies.) +- 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 @@ -278,6 +280,71 @@ 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(...)`. + ## Single-file `Program.cs` — what it looks like The shipping `Opc.Ua.Server.Hosting.AddOpcUaServer(...)` extension wires the @@ -384,5 +451,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`). From cf3f1f3c9c056611544df7072851247fb0c3a314 Mon Sep 17 00:00:00 2001 From: Marc Schier Date: Wed, 13 May 2026 16:01:25 +0200 Subject: [PATCH 15/19] Add Publish runtime foundation for fluent event notifiers Adds the runtime that lets fluent users register IAsyncEnumerable sources on event-notifier nodes via builder.Node<...>().Publish(...). FluentNodeManagerBase opt-in base owns an EventSourceRegistry that activates and deactivates streams in lock-step with NodeState.AreEventsMonitored, dispatches events through node.ReportEvent, and tears down cleanly on Dispose. EventPublishOptions exposes lazy / eager activation, RegisterAsRootNotifier, OnError, and a CancellationTimeout for shutdown. Wires the registry into NodeManagerBuilder via FluentNodeManagerBase.AttachToBuilder(...) which the source-generator-emitted CreateAddressSpaceAsync calls right before the user's Configure partial runs (a no-op for non-fluent managers, so behavior is unchanged for existing CustomNodeManager2-based generators). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Fluent/EventNotifierBuilderExtensions.cs | 177 +++++ .../Fluent/EventPublishOptions.cs | 110 +++ .../Fluent/EventSourceRegistry.cs | 646 ++++++++++++++++++ .../Fluent/FluentNodeManagerBase.cs | 221 ++++++ .../Fluent/NodeManagerBuilder.cs | 38 ++ .../Fluent/TypedBuilderTests.cs | 2 +- .../Generators/NodeManagerTemplates.cs | 6 + 7 files changed, 1199 insertions(+), 1 deletion(-) create mode 100644 Libraries/Opc.Ua.Server/Fluent/EventNotifierBuilderExtensions.cs create mode 100644 Libraries/Opc.Ua.Server/Fluent/EventPublishOptions.cs create mode 100644 Libraries/Opc.Ua.Server/Fluent/EventSourceRegistry.cs create mode 100644 Libraries/Opc.Ua.Server/Fluent/FluentNodeManagerBase.cs 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/NodeManagerBuilder.cs b/Libraries/Opc.Ua.Server/Fluent/NodeManagerBuilder.cs index d2039d1f0a..afc5ef6b85 100644 --- a/Libraries/Opc.Ua.Server/Fluent/NodeManagerBuilder.cs +++ b/Libraries/Opc.Ua.Server/Fluent/NodeManagerBuilder.cs @@ -291,6 +291,44 @@ internal VariableBuilder ToVariableBuilder(NodeState node, strin 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`; diff --git a/Tests/Opc.Ua.Server.Tests/Fluent/TypedBuilderTests.cs b/Tests/Opc.Ua.Server.Tests/Fluent/TypedBuilderTests.cs index 18e6bbc1fa..4f8269d729 100644 --- a/Tests/Opc.Ua.Server.Tests/Fluent/TypedBuilderTests.cs +++ b/Tests/Opc.Ua.Server.Tests/Fluent/TypedBuilderTests.cs @@ -417,7 +417,7 @@ public async Task OnWriteAsyncReceivesContextAndCancellationToken() { seenContext = c; seenToken = ct; - return ValueTask.CompletedTask; + return default; }); SystemContext ctx = CreateContext(); diff --git a/Tools/Opc.Ua.SourceGeneration.Core/Generators/NodeManagerTemplates.cs b/Tools/Opc.Ua.SourceGeneration.Core/Generators/NodeManagerTemplates.cs index 5a3de73678..86b464660d 100644 --- a/Tools/Opc.Ua.SourceGeneration.Core/Generators/NodeManagerTemplates.cs +++ b/Tools/Opc.Ua.SourceGeneration.Core/Generators/NodeManagerTemplates.cs @@ -114,6 +114,12 @@ public partial class {{Tokens.NodeManagerClassName}} : global::Opc.Ua.Server.Asy __FindRootByNodeId, __FindByTypeDefinitionId); + // Fluent managers carry an EventSourceRegistry for + // Publish(...) bindings; attach it before Configure + // so extension methods can resolve it. No-op for + // non-fluent managers. + (this as global::Opc.Ua.Server.Fluent.FluentNodeManagerBase)?.AttachToBuilder(__m_builder); + Configure(__m_builder); Configure(new {{Tokens.NodeManagerClassName}}TypedBuilder(__m_builder)); __m_builder.Seal(); From 306c00424c0b1cd25ff1ef118d436107ebca0fd0 Mon Sep 17 00:00:00 2001 From: Marc Schier Date: Wed, 13 May 2026 17:15:25 +0200 Subject: [PATCH 16/19] Add PublishTests covering EventSourceRegistry behavior Adds 21 unit tests covering the runtime introduced in cf3f1f3c9: lazy/eager activation, monitor/unmonitor cycles, event delivery and default-population, SkipDefaultPopulation, factory and iterator error paths with OnError, duplicate registration, timeout validation, null-arg validation, EventNotifier auto-promotion, root-notifier registration, dispose cancellation propagation, and the Publish extension on attached vs. non-attached builders. Tests use a TestablePublishManager that exposes the protected RootNotifiers/PredefinedNodes accessors and a helper to drop a notifier into the predefined-nodes table without requiring a full master-node-manager wiring. Race-free assertions use TaskCompletionSource hooks instead of polling. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Fluent/PublishTests.cs | 825 ++++++++++++++++++ 1 file changed, 825 insertions(+) create mode 100644 Tests/Opc.Ua.Server.Tests/Fluent/PublishTests.cs 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..ca83771db3 --- /dev/null +++ b/Tests/Opc.Ua.Server.Tests/Fluent/PublishTests.cs @@ -0,0 +1,825 @@ +/* ======================================================================== + * 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/"; + private static readonly TimeSpan s_signalTimeout = TimeSpan.FromSeconds(5); + 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 iteratorObservedCancel = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + + manager.EventSources.Register( + notifier, + (_, _, ct) => CancelObservingStream(iteratorObservedCancel, ct), + options: null); + + notifier.SetAreEventsMonitored(manager.SystemContext, true, false); + manager.EventSources.SignalReconcile(); + + // Give the worker a chance to enter the iterator. + await Task.Delay(50).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 iteratorObservedCancel = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + + manager.EventSources.Register( + notifier, + (_, _, ct) => CancelObservingStream(iteratorObservedCancel, ct), + new EventPublishOptions { AlwaysOn = true }); + + // Give the worker a chance to enter the iterator before we tear down. + await Task.Delay(50).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 observedCancel, + [EnumeratorCancellation] CancellationToken ct) + { + try + { + await Task.Delay(Timeout.Infinite, ct).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + observedCancel.TrySetResult(true); + throw; + } + yield break; + } + + private static async IAsyncEnumerable ThrowingStream( + Exception toThrow, + [EnumeratorCancellation] CancellationToken ct) + { + await Task.Yield(); + ct.ThrowIfCancellationRequested(); + throw toThrow; +#pragma warning disable CS0162 + yield break; +#pragma warning restore CS0162 + } + + private static async IAsyncEnumerable EmptyStream( + [EnumeratorCancellation] CancellationToken ct) + { + await Task.Yield(); + ct.ThrowIfCancellationRequested(); + yield break; + } + + private sealed class AsyncCountdown + { + private int m_remaining; + private readonly TaskCompletionSource m_done = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public AsyncCountdown(int target) => m_remaining = target; + + public void SignalOne() + { + if (Interlocked.Decrement(ref m_remaining) == 0) + { + m_done.TrySetResult(true); + } + } + + public Task WaitAsync() => m_done.Task; + } + + private static class AsyncEnumerable + { + public static IAsyncEnumerable Empty() => EmptyImpl(); + + private static async IAsyncEnumerable EmptyImpl( + [EnumeratorCancellation] CancellationToken ct = default) + { + await Task.Yield(); + ct.ThrowIfCancellationRequested(); + yield break; + } + } + + public class TestablePublishManager : FluentNodeManagerBase + { + public TestablePublishManager( + IServerInternal server, + ApplicationConfiguration configuration, + ILogger logger, + params string[] namespaceUris) + : base(server, configuration, logger, namespaceUris) + { + } + + public new NodeIdDictionary RootNotifiers => base.RootNotifiers; + + public new NodeIdDictionary PredefinedNodes => base.PredefinedNodes; + + public ValueTask AddPublic( + BaseInstanceState node, + CancellationToken cancellationToken = default) + { + return AddNodeAsync(SystemContext, default, node, cancellationToken); + } + } + + #endregion + } +} From 15114a682261f8a6fcd50b0416c6f7d3fcfed704 Mon Sep 17 00:00:00 2001 From: Marc Schier Date: Wed, 13 May 2026 18:42:11 +0200 Subject: [PATCH 17/19] Switch generated NodeManager base to FluentNodeManagerBase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generated NodeManager partials now inherit from FluentNodeManagerBase instead of AsyncCustomNodeManager. Behavior is unchanged for managers without Publish bindings — FluentNodeManagerBase only adds an idle EventSourceRegistry that is disposed with the manager. With the base type fixed, the conditional cast in CreateAddressSpaceAsync collapses to a direct AttachToBuilder(__m_builder) call which makes Publish(...) extensions usable without any user opt-in. Updates the snapshot assertion in NodeManagerGeneratorTests accordingly. Also stabilizes PublishTests under parallel CPU load: bumps s_signalTimeout from 5s to 15s and replaces the two hardcoded 50ms warm-up sleeps with TaskCompletionSource hooks signaled when the worker has actually entered the iterator. Verified: source-generator tests 3511/3511, AOT end-to-end 77/77, fluent server tests 103/103 (×5 TFMs). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Fluent/PublishTests.cs | 27 ++++++++++++++----- .../Generators/NodeManagerGeneratorTests.cs | 2 +- .../Generators/NodeManagerTemplates.cs | 11 ++++---- 3 files changed, 26 insertions(+), 14 deletions(-) diff --git a/Tests/Opc.Ua.Server.Tests/Fluent/PublishTests.cs b/Tests/Opc.Ua.Server.Tests/Fluent/PublishTests.cs index ca83771db3..51405db374 100644 --- a/Tests/Opc.Ua.Server.Tests/Fluent/PublishTests.cs +++ b/Tests/Opc.Ua.Server.Tests/Fluent/PublishTests.cs @@ -52,7 +52,11 @@ public class PublishTests { private const ushort kNs = 2; private const string kNamespaceUri = "http://test.org/UA/Publish/"; - private static readonly TimeSpan s_signalTimeout = TimeSpan.FromSeconds(5); + // 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; @@ -158,19 +162,23 @@ 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(iteratorObservedCancel, ct), + (_, _, ct) => CancelObservingStream(iteratorEntered, iteratorObservedCancel, ct), options: null); notifier.SetAreEventsMonitored(manager.SystemContext, true, false); manager.EventSources.SignalReconcile(); - // Give the worker a chance to enter the iterator. - await Task.Delay(50).ConfigureAwait(false); + // 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(); @@ -509,16 +517,19 @@ 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(iteratorObservedCancel, ct), + (_, _, ct) => CancelObservingStream(iteratorEntered, iteratorObservedCancel, ct), new EventPublishOptions { AlwaysOn = true }); - // Give the worker a chance to enter the iterator before we tear down. - await Task.Delay(50).ConfigureAwait(false); + // 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(); @@ -730,9 +741,11 @@ private static async IAsyncEnumerable CountingStream( } private static async IAsyncEnumerable CancelObservingStream( + TaskCompletionSource iteratorEntered, TaskCompletionSource observedCancel, [EnumeratorCancellation] CancellationToken ct) { + iteratorEntered.TrySetResult(true); try { await Task.Delay(Timeout.Infinite, ct).ConfigureAwait(false); diff --git a/Tests/Opc.Ua.SourceGeneration.Core.Tests/Generators/NodeManagerGeneratorTests.cs b/Tests/Opc.Ua.SourceGeneration.Core.Tests/Generators/NodeManagerGeneratorTests.cs index c47b6319e6..d7706ee2bb 100644 --- a/Tests/Opc.Ua.SourceGeneration.Core.Tests/Generators/NodeManagerGeneratorTests.cs +++ b/Tests/Opc.Ua.SourceGeneration.Core.Tests/Generators/NodeManagerGeneratorTests.cs @@ -78,7 +78,7 @@ public void EmittedNodeManager_HasRequiredStructuralMembers() string mgr = files.Single(kv => kv.Key.EndsWith(".NodeManager.g.cs", StringComparison.Ordinal)).Value; // Inheritance and partial — required so users can extend. - Assert.That(mgr, Does.Contain(": global::Opc.Ua.Server.AsyncCustomNodeManager")); + Assert.That(mgr, Does.Contain(": global::Opc.Ua.Server.Fluent.FluentNodeManagerBase")); Assert.That(mgr, Does.Match(@"public\s+partial\s+class\s+\w+NodeManager")); // Lifecycle overrides that wire the runtime fluent dispatcher. diff --git a/Tools/Opc.Ua.SourceGeneration.Core/Generators/NodeManagerTemplates.cs b/Tools/Opc.Ua.SourceGeneration.Core/Generators/NodeManagerTemplates.cs index 86b464660d..e692ac160c 100644 --- a/Tools/Opc.Ua.SourceGeneration.Core/Generators/NodeManagerTemplates.cs +++ b/Tools/Opc.Ua.SourceGeneration.Core/Generators/NodeManagerTemplates.cs @@ -58,7 +58,7 @@ namespace {{Tokens.NamespacePrefix}} /// fluent API in Opc.Ua.Server.Fluent. /// [global::System.CodeDom.Compiler.GeneratedCodeAttribute("{{Tokens.Tool}}", "{{Tokens.Version}}")] - public partial class {{Tokens.NodeManagerClassName}} : global::Opc.Ua.Server.AsyncCustomNodeManager + public partial class {{Tokens.NodeManagerClassName}} : global::Opc.Ua.Server.Fluent.FluentNodeManagerBase { private global::Opc.Ua.Server.Fluent.NodeManagerBuilder __m_builder; @@ -114,11 +114,10 @@ public partial class {{Tokens.NodeManagerClassName}} : global::Opc.Ua.Server.Asy __FindRootByNodeId, __FindByTypeDefinitionId); - // Fluent managers carry an EventSourceRegistry for - // Publish(...) bindings; attach it before Configure - // so extension methods can resolve it. No-op for - // non-fluent managers. - (this as global::Opc.Ua.Server.Fluent.FluentNodeManagerBase)?.AttachToBuilder(__m_builder); + // Attach the FluentNodeManagerBase event-source registry + // to the builder so Publish(...) extensions can resolve + // it before the user's Configure partial(s) run. + AttachToBuilder(__m_builder); Configure(__m_builder); Configure(new {{Tokens.NodeManagerClassName}}TypedBuilder(__m_builder)); From 9a0d74f7507619ec89fb3b641f72b4f8638c456c Mon Sep 17 00:00:00 2001 From: Marc Schier Date: Wed, 13 May 2026 19:34:51 +0200 Subject: [PATCH 18/19] Emit typed Publish overloads on notifier wrappers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes Phase 3b (pub-gen-typed + pub-snapshot-tests) of the event-source publish runtime work. The fluent builder generator now emits typed Publish overloads on every wrapper whose underlying NodeDesign qualifies as an event notifier in the model. Detection (locked decision: spec-accurate, not blanket): - ObjectDesign.SupportsEvents is set (the design-XML form of EventNotifier=SubscribeToEvents; the model validator already auto-promotes nodes carrying forward HasEventSource/HasNotifier references), OR - the node has any forward GeneratesEvent or AlwaysGeneratesEvent reference in its References collection. Each qualifying wrapper now exposes two overloads matching the runtime extension's shape: - Publish(IAsyncEnumerable, EventPublishOptions?) - Publish(Func>, EventPublishOptions?) Both forward to EventNotifierBuilderExtensions.Publish with the wrapper's underlying state type bound as TNotifier so callers don't need to spell it out. EventPublishOptions is annotated as nullable because the generated file uses #nullable enable. Snapshot coverage in TestModel.xml + FluentBuilderGeneratorTests: - NotifierObject (SupportsEvents=true) -> Publish emitted. - EventEmittingObject (forward GeneratesEvent reference) -> emitted. - TestObject (plain object) -> Publish NOT emitted. Verification: - Source-generator tests: 3511/3511 (8 pre-existing skips) on net10.0. - Fluent + AsyncCustom server tests: 345/345 across 5 TFMs (net472/net48/net8.0/net9.0/net10.0). - AOT integration: 77/77 on net10.0 with Boiler/Calculator generated managers — the Boiler model legitimately produces wrappers with Publish on its state-machine notifiers via SupportsEvents inherited from FiniteStateMachineType. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Generators/FluentBuilderGeneratorTests.cs | 56 ++++++++++ .../Resources/TestModel.xml | 18 +++ .../Generators/FluentBuilderGenerator.cs | 104 ++++++++++++++++++ 3 files changed, 178 insertions(+) diff --git a/Tests/Opc.Ua.SourceGeneration.Core.Tests/Generators/FluentBuilderGeneratorTests.cs b/Tests/Opc.Ua.SourceGeneration.Core.Tests/Generators/FluentBuilderGeneratorTests.cs index 4759759743..cb1b61d17c 100644 --- a/Tests/Opc.Ua.SourceGeneration.Core.Tests/Generators/FluentBuilderGeneratorTests.cs +++ b/Tests/Opc.Ua.SourceGeneration.Core.Tests/Generators/FluentBuilderGeneratorTests.cs @@ -309,6 +309,62 @@ public void EmittedNodeManager_InvokesBothPlainAndTypedConfigurePartials() "Typed Configure call must be present in CreateAddressSpaceAsync"); } + [Test] + public void EmittedFluentBuilders_PublishOverloadEmittedOnSupportsEventsWrapper() + { + string fb = GetFluentBuilders(); + + // TestModel declares NotifierObject with SupportsEvents="true" + // (the design-XML form of EventNotifier=SubscribeToEvents). Its + // wrapper must expose typed Publish overloads bound to + // the wrapper's underlying state type and constrained to + // BaseEventState. + Assert.That(fb, Does.Match( + @"internal\s+sealed\s+class\s+NotifierObjectBuilder\b"), + "NotifierObject must produce a wrapper class"); + Assert.That(fb, Does.Match( + @"public\s+global::Opc\.Ua\.Server\.Fluent\.INodeBuilder\s+Publish\(\s*global::System\.Collections\.Generic\.IAsyncEnumerable\s+source"), + "NotifierObjectBuilder must expose Publish(IAsyncEnumerable, EventPublishOptions)"); + Assert.That(fb, Does.Match( + @"public\s+global::Opc\.Ua\.Server\.Fluent\.INodeBuilder\s+Publish\(\s*global::System\.Func>\s+factory"), + "NotifierObjectBuilder must expose the factory-style Publish overload"); + Assert.That(fb, Does.Contain( + "where TEvent : global::Opc.Ua.BaseEventState"), + "Publish must be constrained to BaseEventState"); + Assert.That(fb, Does.Contain( + "global::Opc.Ua.Server.Fluent.EventNotifierBuilderExtensions.Publish(__node,"), + "Publish overload must forward to EventNotifierBuilderExtensions.Publish with the wrapper's state type as TNotifier"); + } + + [Test] + public void EmittedFluentBuilders_PublishOverloadEmittedOnGeneratesEventWrapper() + { + string fb = GetFluentBuilders(); + + // EventEmittingObject does not set SupportsEvents but declares + // a forward GeneratesEvent reference. The wrapper must still + // expose Publish; the qualification logic walks both + // the SupportsEvents flag and outgoing GeneratesEvent / + // AlwaysGeneratesEvent references. + Assert.That(fb, Does.Match( + @"(?ms)internal\s+sealed\s+class\s+EventEmittingObjectBuilder\b.*?Publish"), + "EventEmittingObjectBuilder must expose Publish because of the GeneratesEvent reference"); + } + + [Test] + public void EmittedFluentBuilders_PublishOverloadAbsentOnPlainObjectWrapper() + { + string fb = GetFluentBuilders(); + + // TestObject (RestrictedObjectType) is a plain object: no + // SupportsEvents, no GeneratesEvent. It must NOT carry a + // typed Publish overload — the surface stays clean and only + // notifier candidates become discoverable through intellisense. + Assert.That(fb, Does.Not.Match( + @"(?ms)internal\s+sealed\s+class\s+TestObjectBuilder\b(?:(?!internal\s+sealed\s+class).)*?Publish"), + "TestObjectBuilder must not expose Publish — it's not a notifier"); + } + private static string GetFluentBuilders() { Dictionary files = GenerateForTestModel(generateNodeManager: true); diff --git a/Tests/Opc.Ua.SourceGeneration.Core.Tests/Resources/TestModel.xml b/Tests/Opc.Ua.SourceGeneration.Core.Tests/Resources/TestModel.xml index 6e3847f32b..0b34428d2a 100644 --- a/Tests/Opc.Ua.SourceGeneration.Core.Tests/Resources/TestModel.xml +++ b/Tests/Opc.Ua.SourceGeneration.Core.Tests/Resources/TestModel.xml @@ -467,6 +467,24 @@ + + + + + + + + ua:GeneratesEvent + ua:BaseEventType + + + + diff --git a/Tools/Opc.Ua.SourceGeneration.Core/Generators/FluentBuilderGenerator.cs b/Tools/Opc.Ua.SourceGeneration.Core/Generators/FluentBuilderGenerator.cs index f59f249308..52246e6366 100644 --- a/Tools/Opc.Ua.SourceGeneration.Core/Generators/FluentBuilderGenerator.cs +++ b/Tools/Opc.Ua.SourceGeneration.Core/Generators/FluentBuilderGenerator.cs @@ -348,6 +348,7 @@ private void RegisterInstanceWrapper( ParentKey = parentKey, NodeStateType = ResolveStateClrType(hnode.Instance), BrowseNamespaceUri = nsUri, + SupportsPublish = QualifiesAsEventNotifier(hnode.Instance), Children = [], ChildObjectKeys = [], ChildMethodKeys = [] @@ -775,6 +776,11 @@ private void EmitInstanceWrapper( EmitChildAccessor(writer, child, memberIndent); } + if (wrapper.SupportsPublish) + { + EmitPublishOverloads(writer, wrapper, memberIndent); + } + // Emit the nested method wrappers, then the nested object // wrappers. Sibling order is leaf-name ordinal (set up by // LinkChildWrappers) so generation is deterministic. @@ -865,6 +871,61 @@ private void EmitChildAccessor( } } + /// + /// Emits the typed Publish<TEvent> overloads on a + /// notifier-capable wrapper. Both overloads forward to + /// Opc.Ua.Server.Fluent.EventNotifierBuilderExtensions; + /// the wrapper's underlying node-state type is bound as the + /// TNotifier type argument so callers don't need to spell + /// it out. The shape mirrors the extension's two overloads (direct + /// stream + factory). + /// + private static void EmitPublishOverloads( + ITemplateWriter writer, + InstanceWrapper wrapper, + string indent) + { + writer.WriteLine(); + writer.WriteLine( + "{0}/// Registers an event source for this notifier; lazy by default. See for activation tuning.", + indent); + writer.WriteLine( + "{0}public global::Opc.Ua.Server.Fluent.INodeBuilder<{1}> Publish(", + indent, wrapper.NodeStateType); + writer.WriteLine( + "{0}global::System.Collections.Generic.IAsyncEnumerable source,", + indent + Indent); + writer.WriteLine( + "{0}global::Opc.Ua.Server.Fluent.EventPublishOptions? options = null)", + indent + Indent); + writer.WriteLine( + "{0}where TEvent : global::Opc.Ua.BaseEventState", + indent + Indent); + writer.WriteLine( + "{0}=> global::Opc.Ua.Server.Fluent.EventNotifierBuilderExtensions.Publish<{1}, TEvent>(__node, source, options);", + indent + Indent, wrapper.NodeStateType); + + writer.WriteLine(); + writer.WriteLine( + "{0}/// Registers a factory-based event source for this notifier; the factory runs on each activation. See for activation tuning.", + indent); + writer.WriteLine( + "{0}public global::Opc.Ua.Server.Fluent.INodeBuilder<{1}> Publish(", + indent, wrapper.NodeStateType); + writer.WriteLine( + "{0}global::System.Func<{1}, global::Opc.Ua.ISystemContext, global::System.Threading.CancellationToken, global::System.Collections.Generic.IAsyncEnumerable> factory,", + indent + Indent, wrapper.NodeStateType); + writer.WriteLine( + "{0}global::Opc.Ua.Server.Fluent.EventPublishOptions? options = null)", + indent + Indent); + writer.WriteLine( + "{0}where TEvent : global::Opc.Ua.BaseEventState", + indent + Indent); + writer.WriteLine( + "{0}=> global::Opc.Ua.Server.Fluent.EventNotifierBuilderExtensions.Publish<{1}, TEvent>(__node, factory, options);", + indent + Indent, wrapper.NodeStateType); + } + /// /// Emits one wrapper class for a method instance with typed /// OnCall overloads at the supplied . @@ -1376,6 +1437,48 @@ private string ResolveNodeBrowseNamespace(NodeDesign node) return m_context.ModelDesign.TargetNamespace?.Value ?? string.Empty; } + /// + /// Returns true when the supplied node should expose typed + /// Publish<TEvent> overloads on its wrapper. A node + /// qualifies if it carries the EventNotifier=SubscribeToEvents + /// attribute (modeled as ; + /// the model validator auto-promotes nodes with forward + /// HasEventSource/HasNotifier references) or has a + /// forward GeneratesEvent/AlwaysGeneratesEvent + /// reference. Per the locked decision the typed overload is + /// emitted only on spec-accurate notifier candidates so call sites + /// don't drift from the model intent. + /// + private static bool QualifiesAsEventNotifier(NodeDesign node) + { + if (node is ObjectDesign od && od.SupportsEvents) + { + return true; + } + + Reference[] references = node?.References; + if (references == null || references.Length == 0) + { + return false; + } + + foreach (Reference reference in references) + { + if (reference == null || reference.IsInverse) + { + continue; + } + string refName = reference.ReferenceType?.Name; + if (refName == "GeneratesEvent" || + refName == "AlwaysGeneratesEvent") + { + return true; + } + } + + return false; + } + /// /// Returns the C# type name of the runtime /// derivative for the supplied instance. @@ -1480,6 +1583,7 @@ private sealed class InstanceWrapper public string ParentKey; public string NodeStateType; public string BrowseNamespaceUri; + public bool SupportsPublish; public List Children; public List ChildObjectKeys = []; public List ChildMethodKeys = []; From aef881137b23eb777ae1d5c45596d67c9c2c3caa Mon Sep 17 00:00:00 2001 From: Marc Schier Date: Wed, 13 May 2026 21:14:45 +0200 Subject: [PATCH 19/19] Wire Publish sample, add AOT round-trip test, document event sources Phase 4-5 of the event-source publish runtime: * MinimalBoilerServer: wire `builder.Boilers.Boiler__1.DrumX001 .Publish(GenerateDrumHeartbeatAsync)` in the typed Configure partial. The async iterator emits a synthetic 500 ms heartbeat event with Severity/Message; the registry auto-populates EventId/EventType/SourceNode/SourceName/Time/ReceiveTime. Lazy activation: the iterator only runs while a client is monitoring. * Tests/Opc.Ua.Aot.Tests/PublishedEventsAotTests.cs: end-to-end AOT round-trip. A real client subscription with EventFilter on EventNotifier of the DrumX001 node receives the heartbeats; asserts SourceName/Severity/Message on the EventFieldList. 78/78 AOT tests pass on net10.0 (was 77). * Docs/SourceGeneratedNodeManagers.md: new `Event sources` section covering the typed `Publish` overload, the two registration shapes (direct stream + factory), `EventPublishOptions` for lifecycle tuning, where the typed overload appears (SupportsEvents OR GeneratesEvent), and the hand-written-manager opt-in via `FluentNodeManagerBase`. Verification: * MinimalBoilerServer: build clean. * Sgen tests: build clean. * Fluent + AsyncCustomNodeManager server tests: 345/345 on net10.0. * AOT tests: 78/78 on net10.0. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../BoilerNodeManager.Configure.cs | 57 +++++ Docs/SourceGeneratedNodeManagers.md | 129 +++++++++++ .../PublishedEventsAotTests.cs | 204 ++++++++++++++++++ 3 files changed, 390 insertions(+) create mode 100644 Tests/Opc.Ua.Aot.Tests/PublishedEventsAotTests.cs diff --git a/Applications/MinimalBoilerServer/BoilerNodeManager.Configure.cs b/Applications/MinimalBoilerServer/BoilerNodeManager.Configure.cs index e2faaad170..ac6389f23d 100644 --- a/Applications/MinimalBoilerServer/BoilerNodeManager.Configure.cs +++ b/Applications/MinimalBoilerServer/BoilerNodeManager.Configure.cs @@ -28,6 +28,8 @@ * ======================================================================*/ using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; @@ -67,6 +69,7 @@ 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) { @@ -150,6 +153,19 @@ partial void Configure(IBoilerNodeManagerBuilder builder) // 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; @@ -170,6 +186,47 @@ private async ValueTask HaltSimulationAsync(CancellationToken cancellationToken) .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, diff --git a/Docs/SourceGeneratedNodeManagers.md b/Docs/SourceGeneratedNodeManagers.md index 71f8100b1d..6965402084 100644 --- a/Docs/SourceGeneratedNodeManagers.md +++ b/Docs/SourceGeneratedNodeManagers.md @@ -345,6 +345,135 @@ 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 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); + } + } +}