Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Stack/Opc.Ua.Types/State/BaseInstanceState.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ protected BaseInstanceState(NodeClass nodeClass, NodeState parent)
Parent = parent;
}

/// <inheritdoc/>
protected override bool RemovePlaceholderChildrenOnCreate => true;

/// <summary>
/// Initializes the instance from another instance.
/// </summary>
Expand Down
80 changes: 80 additions & 0 deletions Stack/Opc.Ua.Types/State/NodeState.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2710,6 +2710,11 @@ public virtual void Create(
{
Initialize(context);

if (RemovePlaceholderChildrenOnCreate)
{
RemovePlaceholderChildren(context);
}

// Call OnBeforeCreate on all children.
CallOnBeforeCreate(context);

Expand Down Expand Up @@ -2819,13 +2824,88 @@ public virtual void Create(ISystemContext context, NodeState source)
{
Initialize(context, source);

if (RemovePlaceholderChildrenOnCreate)
{
RemovePlaceholderChildren(context);
}

CallOnBeforeCreate(context);

CallOnAfterCreate(context, null);

ClearChangeMasks(context, true);
}

/// <summary>
/// Whether placeholder declarations should be removed during runtime creation.
/// </summary>
protected virtual bool RemovePlaceholderChildrenOnCreate => false;

/// <summary>
/// Removes instantiated placeholder declarations from runtime instances.
/// </summary>
private void RemovePlaceholderChildren(ISystemContext context)
{
var children = new List<BaseInstanceState>();
GetChildren(context, children);

for (int ii = 0; ii < children.Count; ii++)
{
BaseInstanceState child = children[ii];

if (IsPlaceholderChild(child))
{
RemoveChild(child);

if (ReferenceEquals(child.Parent, this))
{
child.Parent = null;
}

continue;
}

child.RemovePlaceholderChildren(context);
}
}

/// <summary>
/// Returns true if the child represents a placeholder declaration.
/// </summary>
private static bool IsPlaceholderChild(BaseInstanceState child)
{
if (child.ModellingRuleId == ObjectIds.ModellingRule_OptionalPlaceholder ||
child.ModellingRuleId == ObjectIds.ModellingRule_MandatoryPlaceholder)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why remove MandatoryPlaceholder?

Copy link
Copy Markdown
Contributor Author

@mrsuciu mrsuciu May 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because it is still a place holder declaration not a concrete runtime instance that should not appear as a concrete child in a runtime instance.

Copy link
Copy Markdown
Contributor Author

@mrsuciu mrsuciu May 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

However commits from f673061 upto 699fbe7 are wrong leftovers and need to be removed

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mrsuciu okay, so this restores the behaviour from before 1.5.378? Because Else i would think it could Break Adress spaces of Users that expect those placeholders

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@marcschier whats your opinion in that?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@romanett These placeholders should not exist on the instances, just on the declarations which are part of Types

{
return true;
}

string browseName = child.BrowseName?.Name;

// Some generated placeholder declarations reach runtime without their
// placeholder modelling rule; the fallback is limited to model-defined
// standard nodes that still carry the generated <PlaceholderName>
// browse-name form.
return IsGeneratedStandardNumericNode(child) &&
browseName != null &&
browseName.Length > 1 &&
browseName[0] == '<' &&
browseName[browseName.Length - 1] == '>';
}

/// <summary>
/// Returns true if the child still has its generated standard numeric node identity.
/// </summary>
private static bool IsGeneratedStandardNumericNode(BaseInstanceState child)
{
return child.NumericId != 0 &&
child.NodeId != null &&
child.NodeId.NamespaceIndex == 0 &&
child.NodeId.IdType == IdType.Numeric &&
child.NodeId.Identifier is uint identifier &&
identifier == child.NumericId;
}

/// <summary>
/// Deletes an instance and its children (calls OnStateChange callback for each node).
/// </summary>
Expand Down
204 changes: 204 additions & 0 deletions Tests/Opc.Ua.Core.Tests/Stack/State/NodeStateTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
* ======================================================================*/

using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using NUnit.Framework;
Expand Down Expand Up @@ -84,10 +85,87 @@ public void ActivateNodeStateType(Type systemType)
Assert.NotNull(testObject);
var context = new SystemContext(telemetry) { NamespaceUris = Context.NamespaceUris };
Assert.AreEqual(0, context.NamespaceUris.GetIndexOrAppend(OpcUa));

testObject.Create(context, new NodeId(1000), "Name", "DisplayName", true);
testObject.Dispose();
}

/// <summary>
/// Instantiate NodeState types across Opc.Ua assemblies and fail on placeholder children.
/// </summary>
[Test]
public void NodeStateTypesAcrossOpcUaAssemblies_ShouldNotInstantiatePlaceholderChildren()
{
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
var context = new SystemContext(telemetry) { NamespaceUris = Context.NamespaceUris };
var placeholders = new List<string>();
uint nodeId = 200000;
Type[] nodeStateTypesToScan = [.. GetOpcUaNodeStateTypes().OrderBy(type => type.FullName)];

foreach (Type systemType in nodeStateTypesToScan)
{
var testObject = CreateDefaultNodeStateType(systemType) as NodeState;

if (testObject == null)
{
continue;
}

if (testObject is not BaseInstanceState)
{
testObject.Dispose();
continue;
}

try
{
testObject.Create(context, new NodeId(nodeId++), "Name", "DisplayName", true);
CollectInstantiatedPlaceholders(
context,
testObject,
systemType.Assembly.GetName().Name,
systemType.FullName,
placeholders);
}
finally
{
testObject.Dispose();
}
}

Assert.That(
placeholders,
Is.Empty,
"Instantiated placeholder children were found:" + Environment.NewLine +
string.Join(Environment.NewLine, placeholders));
}

/// <summary>
/// Verify placeholder declarations remain available on type definitions.
/// </summary>
[Test]
public void TypeDefinitions_ShouldKeepPlaceholderChildren()
{
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
var context = new SystemContext(telemetry) { NamespaceUris = Context.NamespaceUris };
var typeDefinition = new BaseObjectTypeState();
var placeholder = new BaseObjectState(typeDefinition)
{
BrowseName = new QualifiedName("<OptionalChild>", 0),
DisplayName = "<OptionalChild>",
ModellingRuleId = ObjectIds.ModellingRule_OptionalPlaceholder
};

typeDefinition.AddChild(placeholder);

typeDefinition.Create(context, new NodeId(1000), "TypeName", "TypeName", true);

var children = new List<BaseInstanceState>();
typeDefinition.GetChildren(context, children);

Assert.That(children, Has.Member(placeholder));
}

/// <summary>
/// Create an instance of a NodeState type with default values.
/// </summary>
Expand Down Expand Up @@ -149,6 +227,132 @@ private static bool IsNodeStateType(Type systemType)

return CreateDefaultNodeStateType(systemType) is NodeState;
}

/// <summary>
/// Recursively collect instantiated placeholder children for diagnostics.
/// </summary>
private static void CollectInstantiatedPlaceholders(
ISystemContext context,
NodeState nodeState,
string ownerAssembly,
string ownerType,
List<string> placeholders)
{
var children = new List<BaseInstanceState>();
nodeState.GetChildren(context, children);

foreach (BaseInstanceState child in children)
{
string browseName = child.BrowseName?.Name ?? string.Empty;
bool hasPlaceholderName =
browseName.Length > 1 &&
browseName[0] == '<' &&
browseName[browseName.Length - 1] == '>';

bool hasPlaceholderModellingRule =
child.ModellingRuleId == ObjectIds.ModellingRule_OptionalPlaceholder ||
child.ModellingRuleId == ObjectIds.ModellingRule_MandatoryPlaceholder;

if (hasPlaceholderName || hasPlaceholderModellingRule)
{
string modellingRule = child.ModellingRuleId?.ToString() ?? "<null>";
placeholders.Add(
$"{ownerAssembly}: {ownerType}: {child.GetDisplayPath()} (BrowseName='{browseName}', ModellingRuleId='{modellingRule}')");
}

CollectInstantiatedPlaceholders(
context,
child,
ownerAssembly,
ownerType,
placeholders);
}
}

/// <summary>
/// Discover loadable public NodeState types from reachable Opc.Ua assemblies.
/// </summary>
private static IEnumerable<Type> GetOpcUaNodeStateTypes()
{
var assemblies = new Dictionary<string, Assembly>(StringComparer.OrdinalIgnoreCase);
var toScan = new Queue<Assembly>();

void TryEnqueueAssembly(Assembly assembly)
{
if (assembly == null || assembly.IsDynamic)
{
return;
}

AssemblyName assemblyName = assembly.GetName();

if (!assemblyName.Name.StartsWith("Opc.Ua", StringComparison.Ordinal))
{
return;
}

if (assemblies.ContainsKey(assembly.FullName))
{
return;
}

assemblies[assembly.FullName] = assembly;
toScan.Enqueue(assembly);
}

TryEnqueueAssembly(typeof(NodeState).Assembly);
TryEnqueueAssembly(typeof(OrderedListState).Assembly);
TryEnqueueAssembly(typeof(StateTypesTests).Assembly);

foreach (Assembly assembly in AppDomain.CurrentDomain.GetAssemblies())
{
TryEnqueueAssembly(assembly);
}

while (toScan.Count > 0)
{
Assembly assembly = toScan.Dequeue();

foreach (AssemblyName reference in assembly.GetReferencedAssemblies())
{
if (!reference.Name.StartsWith("Opc.Ua", StringComparison.Ordinal))
{
continue;
}

try
{
TryEnqueueAssembly(Assembly.Load(reference));
}
catch
{
// Nothing we can do if an assembly fails to load, just skip it.
}
}
}

return assemblies.Values
.SelectMany(GetExportedTypesSafe)
.Where(IsNodeStateType)
.GroupBy(type => type.AssemblyQualifiedName)
.Select(group => group.First());
}

/// <summary>
/// Return exported types while tolerating partial type-load failures.
/// </summary>
private static IEnumerable<Type> GetExportedTypesSafe(Assembly assembly)
{
try
{
return assembly.GetExportedTypes();
}
catch (ReflectionTypeLoadException e)
{
// Continue with loadable public types if some types in the assembly fail to load.
return e.Types.Where(type => type != null && type.IsPublic);
}
}
}

/// <summary>
Expand Down
Loading