From 9ad1f05fe9df58f4a5a917a2816541718c82143f Mon Sep 17 00:00:00 2001 From: Marc Date: Sat, 1 Aug 2026 12:01:41 +0200 Subject: [PATCH 1/5] Make NodeId assignment during a node copy explicit A node copy initializes every child from its source right after creating it, which overwrites whatever NodeId was assigned along the way. Assigning one therefore only consumes identifiers, and permanently leaks them for factories that track outstanding allocations. The copy used to avoid that by wrapping the context in one that reports its NodeIdFactory as absent, which is hard to reason about for anyone implementing a custom node manager. State the intent instead. NodeState gains a FindChild overload carrying assignInstanceNodeIds, a matching CreateChild, and a SupportsInstanceNodeIdAssignmentControl property saying whether a type honours it. Source generated types, MethodState and BaseDataVariableState override both, so a copy of those declines assignment outright and still sees the real context. The wrapper stays as a compatibility fallback and is now applied at the point where control is handed to an override that has no way of being told - either a type that predates the overload, or a derived type that overrides only the four argument FindChild and inherits the capability from a base that does not. A copy also keeps dispatching through the original CreateChild for types that do not opt in, so an override of it still runs. Also fixes MethodState.FindChild returning OutputArguments when asked for InputArguments without createOrReplace. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8cbb8cd0-f0cb-4ab0-bea2-6202fbf69485 --- docs/NodeManagers.md | 39 ++ .../State/BaseDataVariableState.cs | 56 +- src/Opc.Ua.Types/State/MethodState.cs | 61 ++- .../State/NodeIdFactorySuppressedContext.cs | 25 +- src/Opc.Ua.Types/State/NodeState.cs | 131 ++++- .../State/NodeInstanceExtensionsTests.cs | 488 ++++++++++++++++++ .../Generators/NodeStateTemplates.cs | 39 +- 7 files changed, 807 insertions(+), 32 deletions(-) diff --git a/docs/NodeManagers.md b/docs/NodeManagers.md index e09cc4c7d8..4f848dd2e5 100644 --- a/docs/NodeManagers.md +++ b/docs/NodeManagers.md @@ -1592,6 +1592,45 @@ Notes: `ISystemContext.NodeIdFactory`. `AsyncCustomNodeManager` supplies one that allocates from the manager's namespace; override `New` to derive ids from the parent chain instead. +* **A node copy never assigns.** `NodeState.Create(context, source)` + initialises each child from its source right after creating it, which + overwrites any NodeId minted along the way — so minting one would only + consume identifiers, and leak them for factories that track outstanding + allocations. The copy therefore calls + `CreateChild(context, browseName, assignInstanceNodeIds: false)`. + +#### Custom node types and assignment control + +`NodeState` carries a second `FindChild` overload that takes +`assignInstanceNodeIds`, plus a `SupportsInstanceNodeIdAssignmentControl` +property that states whether a type honours it. Source generated types +override both, so a copy of a generated node consumes nothing. + +Hand-written types that override only the four argument `FindChild` keep +working: for them the copy hides the `NodeIdFactory` for its duration, which +is the only channel that reaches an override with no such parameter. To take +the direct path instead, override both members: + +```csharp +protected override bool SupportsInstanceNodeIdAssignmentControl => true; + +protected override BaseInstanceState? FindChild( + ISystemContext context, + QualifiedName browseName, + bool createOrReplace, + BaseInstanceState? replacement, + bool assignInstanceNodeIds) +{ + // ... thread assignInstanceNodeIds into your CreateOrReplace calls + return base.FindChild( + context, browseName, createOrReplace, replacement, assignInstanceNodeIds); +} +``` + +Override the property only together with the five argument `FindChild`. A +hand-written type deriving from a generated one inherits `true`; if it +overrides only the four argument `FindChild` and needs that override to run +during a copy, it must override the property back to `false`. ### Current limitations diff --git a/src/Opc.Ua.Types/State/BaseDataVariableState.cs b/src/Opc.Ua.Types/State/BaseDataVariableState.cs index 1e7275dabe..6d259b8a6e 100644 --- a/src/Opc.Ua.Types/State/BaseDataVariableState.cs +++ b/src/Opc.Ua.Types/State/BaseDataVariableState.cs @@ -165,6 +165,9 @@ public override void GetChildren(ISystemContext context, IList + protected override bool SupportsInstanceNodeIdAssignmentControl => true; + /// /// Finds the child with the specified browse name. /// @@ -179,15 +182,54 @@ public override void GetChildren(ISystemContext context, IList + protected override BaseInstanceState? FindChild( + ISystemContext context, + QualifiedName browseName, + bool createOrReplace, + BaseInstanceState? replacement, + bool assignInstanceNodeIds) + { + if (browseName.IsNull) + { + return null; + } + + return FindDeclaredChild( + context, browseName, createOrReplace, replacement, assignInstanceNodeIds) + ?? base.FindChild( + context, browseName, createOrReplace, replacement, assignInstanceNodeIds); + } + + /// + /// Resolves the EnumStrings property declared by this type. + /// + /// The system context. + /// The browse name to resolve. + /// Whether a missing child is created. + /// The replacement to adopt, if any. + /// + /// Whether a newly created child may be given a per-instance NodeId. + /// + /// The child, or null when this type does not declare it. + private PropertyState>? FindDeclaredChild( + ISystemContext context, + QualifiedName browseName, + bool createOrReplace, + BaseInstanceState? replacement, + bool assignInstanceNodeIds) + { + if (browseName.Name == BrowseNames.EnumStrings) { - case BrowseNames.EnumStrings: - instance = !createOrReplace ? - EnumStrings : CreateOrReplaceEnumStrings(context, replacement); - break; + return !createOrReplace + ? EnumStrings + : CreateOrReplaceEnumStrings(context, replacement, assignInstanceNodeIds); } - return instance ?? base.FindChild(context, browseName, createOrReplace, replacement); + return null; } /// diff --git a/src/Opc.Ua.Types/State/MethodState.cs b/src/Opc.Ua.Types/State/MethodState.cs index 1bf09e72d7..03ab9438ab 100644 --- a/src/Opc.Ua.Types/State/MethodState.cs +++ b/src/Opc.Ua.Types/State/MethodState.cs @@ -517,6 +517,9 @@ public override void GetChildren(ISystemContext context, IList + protected override bool SupportsInstanceNodeIdAssignmentControl => true; + /// protected override BaseInstanceState? FindChild( ISystemContext context, @@ -528,19 +531,61 @@ public override void GetChildren(ISystemContext context, IList + protected override BaseInstanceState? FindChild( + ISystemContext context, + QualifiedName browseName, + bool createOrReplace, + BaseInstanceState? replacement, + bool assignInstanceNodeIds) + { + if (browseName.IsNull) + { + return null; + } + return FindDeclaredChild( + context, browseName, createOrReplace, replacement, assignInstanceNodeIds) + ?? base.FindChild( + context, browseName, createOrReplace, replacement, assignInstanceNodeIds); + } + + /// + /// Resolves one of the arguments properties declared by this type. + /// + /// The system context. + /// The browse name to resolve. + /// Whether a missing child is created. + /// The replacement to adopt, if any. + /// + /// Whether a newly created child may be given a per-instance NodeId. + /// + /// The child, or null when this type does not declare it. + private PropertyState>? FindDeclaredChild( + ISystemContext context, + QualifiedName browseName, + bool createOrReplace, + BaseInstanceState? replacement, + bool assignInstanceNodeIds) + { switch (browseName.Name) { case BrowseNames.InputArguments: - instance = !createOrReplace ? - OutputArguments : CreateOrReplaceInputArguments(context, replacement); - break; + return !createOrReplace + ? InputArguments + : CreateOrReplaceInputArguments( + context, replacement, assignInstanceNodeIds); case BrowseNames.OutputArguments: - instance = !createOrReplace ? - OutputArguments : CreateOrReplaceOutputArguments(context, replacement); - break; + return !createOrReplace + ? OutputArguments + : CreateOrReplaceOutputArguments( + context, replacement, assignInstanceNodeIds); + default: + return null; } - return instance ?? base.FindChild(context, browseName, createOrReplace, replacement); } /// diff --git a/src/Opc.Ua.Types/State/NodeIdFactorySuppressedContext.cs b/src/Opc.Ua.Types/State/NodeIdFactorySuppressedContext.cs index 41bcea20da..042c6430d2 100644 --- a/src/Opc.Ua.Types/State/NodeIdFactorySuppressedContext.cs +++ b/src/Opc.Ua.Types/State/NodeIdFactorySuppressedContext.cs @@ -36,17 +36,20 @@ namespace Opc.Ua /// , which is reported as absent. /// /// - /// A node copy materialises its children through - /// , and the - /// CreateOrReplace<Child> plumbing behind it assigns a - /// per-instance NodeId whenever the context carries a factory. In a copy - /// every child is initialised from its source immediately afterwards, which - /// overwrites that NodeId, so the assignment only consumes identifiers - - /// and permanently leaks them for factories that track outstanding - /// allocations. Hiding the factory for the duration of the copy leaves - /// those identifiers unused; the assignInstanceNodeIds flag cannot - /// serve here because it is not part of the virtual FindChild - /// contract the copy goes through. + /// Compatibility fallback for the node copy in + /// . A copy + /// materialises its children and then initialises each one from its source, + /// which overwrites whatever NodeId was assigned along the way, so assigning + /// one only consumes identifiers - and permanently leaks them for factories + /// that track outstanding allocations. + /// + /// A node type that reports + /// NodeState.SupportsInstanceNodeIdAssignmentControl is simply asked + /// not to assign, and never sees this wrapper. It exists only for types that + /// override the four argument FindChild and therefore have no way of + /// being told - hiding the factory is the one channel that reaches them. + /// Remove it once that overload is no longer supported. + /// /// internal sealed class NodeIdFactorySuppressedContext : ISystemContext { diff --git a/src/Opc.Ua.Types/State/NodeState.cs b/src/Opc.Ua.Types/State/NodeState.cs index 18d3a6bdfb..6944bfa72a 100644 --- a/src/Opc.Ua.Types/State/NodeState.cs +++ b/src/Opc.Ua.Types/State/NodeState.cs @@ -337,15 +337,23 @@ protected virtual void Initialize(ISystemContext context, NodeState source) // Every child created below is initialized from its source right // afterwards, which overwrites the NodeId a factory would hand out - // here, so the copy must not reach the factory at all. - ISystemContext childContext = children.Count > 0 && context.NodeIdFactory != null - ? new NodeIdFactorySuppressedContext(context) - : context; + // here, so the copy must not consume identifiers for them. Types + // that understand the request are told not to assign; the rest have + // the factory hidden from them for the duration of the copy because + // their FindChild override has no way to be told. + bool suppressViaContext = !SupportsInstanceNodeIdAssignmentControl; + ISystemContext childContext = + suppressViaContext && children.Count > 0 && context.NodeIdFactory != null + ? new NodeIdFactorySuppressedContext(context) + : context; for (int ii = 0; ii < children.Count; ii++) { BaseInstanceState sourceChild = children[ii]; - BaseInstanceState? child = CreateChild(childContext, sourceChild.BrowseName); + BaseInstanceState? child = CreateChild( + childContext, + sourceChild.BrowseName, + assignInstanceNodeIds: false); if (child == null) { @@ -637,6 +645,31 @@ public AccessRestrictionType? AccessRestrictions /// public bool DesignToolOnly { get; set; } + /// + /// Whether this type honours the assignInstanceNodeIds argument of + /// . + /// + /// + /// Source generated node types override this to true because they + /// thread the argument through to their CreateOrReplace<Child> + /// helpers. A hand written type that overrides only the four argument + /// FindChild leaves it false, and a node copy then keeps + /// dispatching through + /// and hides the + /// from it instead, so + /// identifiers are still not consumed for children whose NodeId is about + /// to be overwritten. Override this together with the five argument + /// FindChild, never on its own. + /// + /// A hand written type deriving from a source generated one inherits + /// true. That is safe - the factory is still hidden from any four + /// argument override reached from here - but such a type only sees the + /// children its generated base does not itself declare. Override this + /// back to false if the four argument override must run first. + /// + /// + protected virtual bool SupportsInstanceNodeIdAssignmentControl => false; + /// /// Exports a copy of the node to a node table. /// @@ -4702,6 +4735,45 @@ protected virtual ServiceResult WriteValueAttribute( return FindChild(context, browseName, true, null); } + /// + /// Finds or creates the child with the specified browse name, stating + /// whether the child should be given a per-instance NodeId. + /// + /// + /// A caller that overwrites the child's NodeId immediately afterwards - + /// a node copy is the canonical case - passes false so the + /// is never asked for an + /// identifier that is about to be discarded. Only node types that report + /// honour the + /// request; for every other type this behaves exactly like + /// . + /// + /// The context to use. + /// The browse name. + /// + /// Whether a newly created child may be given a per-instance NodeId. + /// + /// The child if available. Null otherwise. + public virtual BaseInstanceState? CreateChild( + ISystemContext context, + QualifiedName browseName, + bool assignInstanceNodeIds) + { + if (browseName.IsNull) + { + return null; + } + + if (!SupportsInstanceNodeIdAssignmentControl) + { + // Keep dispatching through the original virtual so a type that + // overrides only that one is still the thing that runs. + return CreateChild(context, browseName); + } + + return FindChild(context, browseName, true, null, assignInstanceNodeIds); + } + /// /// Creates or replaces the child with the same browse name. /// @@ -5352,6 +5424,55 @@ public virtual void GetReferences( } } + /// + /// Finds the child with the specified browse name, stating whether a + /// newly created child should be given a per-instance NodeId. + /// + /// + /// The base implementation forwards to + /// , + /// so a type that overrides only that method keeps working unchanged. It + /// hides the while doing so + /// when assignment was declined, because an override with no such + /// parameter cannot be told any other way. + /// + /// A type that honours the request overrides this method and reports + /// as true, + /// which is what lets a node copy decline assignment outright while + /// still showing that type the real context. + /// + /// + /// The context for the system being accessed. + /// The browse name of the children to add. + /// if set to true and the child does + /// not exist then the child is created or replaced with the provided + /// replacement. + /// The replacement to use if createOrReplace is + /// true. + /// + /// Whether a newly created child may be given a per-instance NodeId. + /// + /// The child. + protected virtual BaseInstanceState? FindChild( + ISystemContext context, + QualifiedName browseName, + bool createOrReplace, + BaseInstanceState? replacement, + bool assignInstanceNodeIds) + { + // Control passes here to hand off to an override that has no way of + // being told not to assign - either because the type predates this + // overload, or because a derived type only overrides the four + // argument one. Hiding the factory is the only channel that reaches + // it, so a request not to assign is honoured even then. + ISystemContext childContext = + !assignInstanceNodeIds && context.NodeIdFactory != null + ? new NodeIdFactorySuppressedContext(context) + : context; + + return FindChild(childContext, browseName, createOrReplace, replacement); + } + /// /// Finds the child with the specified browse name. /// diff --git a/tests/Opc.Ua.Types.Tests/State/NodeInstanceExtensionsTests.cs b/tests/Opc.Ua.Types.Tests/State/NodeInstanceExtensionsTests.cs index 37381e1ebf..f6be3d37cf 100644 --- a/tests/Opc.Ua.Types.Tests/State/NodeInstanceExtensionsTests.cs +++ b/tests/Opc.Ua.Types.Tests/State/NodeInstanceExtensionsTests.cs @@ -83,6 +83,252 @@ public NodeId New(ISystemContext context, NodeState node) } } + /// + /// Records every identifier it hands out. A copy must not consume any, + /// because each child is initialized from its source right afterwards + /// and the assigned NodeId is discarded. + /// + private sealed class CountingNodeIdFactory : INodeIdFactory + { + public int Handouts { get; private set; } + + public NodeId New(ISystemContext context, NodeState node) + { + Handouts++; + return new NodeId(++m_nextId, 3); + } + + private uint m_nextId; + } + + /// + /// A hand written type that declares a child through the four argument + /// FindChild only, standing in for a custom node manager written + /// before the assignment control overload existed. + /// + private sealed class LegacyOwnerState : BaseObjectState + { + public LegacyOwnerState(NodeState parent) + : base(parent) + { + } + + public PropertyState Detail { get; private set; } + + /// + /// Whether a NodeIdFactory was visible the last time a child was + /// created. A copy hides it from this type, because there is no way + /// to tell a four argument override not to assign. + /// + public bool SawNodeIdFactory { get; private set; } + + public override void GetChildren( + ISystemContext context, + IList children) + { + if (Detail != null) + { + children.Add(Detail); + } + base.GetChildren(context, children); + } + + protected override BaseInstanceState FindChild( + ISystemContext context, + QualifiedName browseName, + bool createOrReplace, + BaseInstanceState replacement) + { + if (browseName.Name == "Detail") + { + if (!createOrReplace) + { + return Detail; + } + SawNodeIdFactory = context.NodeIdFactory != null; + Detail ??= new PropertyState(this) + { + SymbolicName = "Detail", + BrowseName = new QualifiedName("Detail", 3), + ReferenceTypeId = ReferenceTypeIds.HasProperty + }; + if (context.NodeIdFactory != null && Detail.NodeId.IsNull) + { + Detail.NodeId = context.NodeIdFactory.New(context, Detail); + } + return Detail; + } + return base.FindChild(context, browseName, createOrReplace, replacement); + } + } + + /// + /// A hand written type deriving from a type that already opts into + /// assignment control, overriding only the four argument + /// FindChild. It inherits the capability as true, so the + /// copy does not wrap the context up front - the factory must still be + /// hidden by the time control reaches this override. + /// + private sealed class DerivedMethodState : MethodState + { + public DerivedMethodState(NodeState parent) + : base(parent) + { + } + + public PropertyState Extra { get; private set; } + + public override void GetChildren( + ISystemContext context, + IList children) + { + if (Extra != null) + { + children.Add(Extra); + } + base.GetChildren(context, children); + } + + protected override BaseInstanceState FindChild( + ISystemContext context, + QualifiedName browseName, + bool createOrReplace, + BaseInstanceState replacement) + { + if (browseName.Name == "Extra") + { + if (!createOrReplace) + { + return Extra; + } + Extra ??= new PropertyState(this) + { + SymbolicName = "Extra", + BrowseName = new QualifiedName("Extra", 3), + ReferenceTypeId = ReferenceTypeIds.HasProperty + }; + if (context.NodeIdFactory != null && Extra.NodeId.IsNull) + { + Extra.NodeId = context.NodeIdFactory.New(context, Extra); + } + return Extra; + } + return base.FindChild(context, browseName, createOrReplace, replacement); + } + } + + /// + /// A hand written type that intercepts child creation by overriding the + /// public CreateChild rather than FindChild. + /// + private sealed class CreateChildOverrideState : BaseObjectState + { + public CreateChildOverrideState(NodeState parent) + : base(parent) + { + } + + public int CreateChildCalls { get; private set; } + + public override BaseInstanceState CreateChild( + ISystemContext context, + QualifiedName browseName) + { + CreateChildCalls++; + return base.CreateChild(context, browseName); + } + } + + /// + /// A hand written type that opts into assignment control, standing in + /// for what the source generator now emits. + /// + private sealed class ModernOwnerState : BaseObjectState + { + public ModernOwnerState(NodeState parent) + : base(parent) + { + } + + public PropertyState Detail { get; private set; } + + /// + /// Whether a NodeIdFactory was visible the last time a child was + /// created. This type is asked not to assign, so it keeps seeing + /// the real context. + /// + public bool SawNodeIdFactory { get; private set; } + + protected override bool SupportsInstanceNodeIdAssignmentControl => true; + + public override void GetChildren( + ISystemContext context, + IList children) + { + if (Detail != null) + { + children.Add(Detail); + } + base.GetChildren(context, children); + } + + protected override BaseInstanceState FindChild( + ISystemContext context, + QualifiedName browseName, + bool createOrReplace, + BaseInstanceState replacement) + { + // Never forward to the five argument overload: its unmatched + // path returns here through the base and would recurse forever. + return FindDeclaredChild(context, browseName, createOrReplace, true) + ?? base.FindChild(context, browseName, createOrReplace, replacement); + } + + protected override BaseInstanceState FindChild( + ISystemContext context, + QualifiedName browseName, + bool createOrReplace, + BaseInstanceState replacement, + bool assignInstanceNodeIds) + { + return FindDeclaredChild( + context, browseName, createOrReplace, assignInstanceNodeIds) + ?? base.FindChild( + context, browseName, createOrReplace, replacement, + assignInstanceNodeIds); + } + + private PropertyState FindDeclaredChild( + ISystemContext context, + QualifiedName browseName, + bool createOrReplace, + bool assignInstanceNodeIds) + { + if (browseName.Name != "Detail") + { + return null; + } + if (!createOrReplace) + { + return Detail; + } + SawNodeIdFactory = context.NodeIdFactory != null; + Detail ??= new PropertyState(this) + { + SymbolicName = "Detail", + BrowseName = new QualifiedName("Detail", 3), + ReferenceTypeId = ReferenceTypeIds.HasProperty + }; + if (assignInstanceNodeIds && + context.NodeIdFactory != null && + Detail.NodeId.IsNull) + { + Detail.NodeId = context.NodeIdFactory.New(context, Detail); + } + return Detail; + } + } + private static SystemContext CreateContext(INodeIdFactory factory) { ITelemetryContext telemetry = NUnitTelemetryContext.Create(); @@ -349,6 +595,248 @@ public void CreateOrReplaceArgumentsHonoursTheAssignmentOptOut() "Callers building declaration subtrees must keep control of the NodeIds."); } + /// + /// A copy initializes every child from its source right after creating + /// it, which overwrites any NodeId handed out along the way. Consuming + /// identifiers for them therefore only burns - and for factories that + /// track outstanding allocations, leaks - them. + /// + [Test] + public void CopyOfTypeWithAssignmentControlConsumesNoNodeIds() + { + var factory = new CountingNodeIdFactory(); + SystemContext context = CreateContext(factory); + + MethodState source = CreateMethod("Start", 1); + source.CreateOrReplaceInputArguments(context, null); + source.CreateOrReplaceOutputArguments(context, null); + int handoutsAfterSource = factory.Handouts; + + var copy = new MethodState(null); + copy.Create(context, source); + + Assert.That(factory.Handouts, Is.EqualTo(handoutsAfterSource), + "Copying must not consume identifiers for children whose NodeId " + + "is overwritten from the source immediately afterwards."); + } + + /// + /// The copy must still reproduce the source subtree while consuming + /// nothing, otherwise the optimisation would have changed behaviour. + /// + [Test] + public void CopyOfTypeWithAssignmentControlReproducesTheSource() + { + var factory = new CountingNodeIdFactory(); + SystemContext context = CreateContext(factory); + + MethodState source = CreateMethod("Start", 1); + PropertyState> sourceArguments = + source.CreateOrReplaceInputArguments(context, null); + + var copy = new MethodState(null); + copy.Create(context, source); + + Assert.That(copy.NodeId, Is.EqualTo(source.NodeId)); + Assert.That(copy.InputArguments, Is.Not.Null); + Assert.That(copy.InputArguments.NodeId, Is.EqualTo(sourceArguments.NodeId), + "A copied child must carry the source NodeId, not a freshly minted one."); + } + + /// + /// A type that predates the assignment control overload still reaches + /// its own four argument FindChild during a copy, and the factory is + /// hidden from it so it cannot consume identifiers either. + /// + [Test] + public void CopyOfLegacyTypeStillConsumesNoNodeIds() + { + var factory = new CountingNodeIdFactory(); + SystemContext context = CreateContext(factory); + + var source = new LegacyOwnerState(null) + { + NodeId = new NodeId("Owner", 3), + SymbolicName = "Owner", + BrowseName = new QualifiedName("Owner", 3) + }; + source.CreateChild(context, new QualifiedName("Detail", 3)); + Assert.That(source.Detail, Is.Not.Null); + int handoutsAfterSource = factory.Handouts; + + var copy = new LegacyOwnerState(null); + copy.Create(context, source); + + Assert.That(copy.Detail, Is.Not.Null, + "The legacy four argument override must still be reached by a copy."); + Assert.That(factory.Handouts, Is.EqualTo(handoutsAfterSource), + "The factory stays hidden from types that cannot be told not to assign."); + } + + /// + /// The fallback works by hiding the factory, which is why it is only + /// used for types that cannot be told not to assign. + /// + [Test] + public void CopyOfLegacyTypeHidesTheNodeIdFactory() + { + var factory = new CountingNodeIdFactory(); + SystemContext context = CreateContext(factory); + + var source = new LegacyOwnerState(null) + { + NodeId = new NodeId("Owner", 3), + SymbolicName = "Owner", + BrowseName = new QualifiedName("Owner", 3) + }; + source.CreateChild(context, new QualifiedName("Detail", 3)); + + var copy = new LegacyOwnerState(null); + copy.Create(context, source); + + Assert.That(copy.SawNodeIdFactory, Is.False, + "A legacy override can only be stopped by hiding the factory from it."); + } + + /// + /// A type that opts into assignment control is simply asked not to + /// assign, so it keeps seeing the real context. This is the point of + /// the overload: no wrapper misreports the factory as absent. + /// + [Test] + public void CopyOfTypeWithAssignmentControlSeesTheRealContext() + { + var factory = new CountingNodeIdFactory(); + SystemContext context = CreateContext(factory); + + var source = new ModernOwnerState(null) + { + NodeId = new NodeId("Owner", 3), + SymbolicName = "Owner", + BrowseName = new QualifiedName("Owner", 3) + }; + source.CreateChild(context, new QualifiedName("Detail", 3)); + int handoutsAfterSource = factory.Handouts; + + var copy = new ModernOwnerState(null); + copy.Create(context, source); + + Assert.That(copy.Detail, Is.Not.Null); + Assert.That(copy.SawNodeIdFactory, Is.True, + "The context must not misreport the factory to a type that " + + "understands the assignment request."); + Assert.That(factory.Handouts, Is.EqualTo(handoutsAfterSource), + "It was asked not to assign, so no identifier may be consumed."); + } + + /// + /// A type deriving from one that opts into assignment control inherits + /// the capability, so the copy shows it the real context. Its four + /// argument override cannot be told to decline, so the factory must be + /// hidden by the time control reaches it - otherwise the inherited + /// true would quietly reintroduce the leak. + /// + [Test] + public void CopyOfDerivedLegacyOverrideConsumesNoNodeIds() + { + var factory = new CountingNodeIdFactory(); + SystemContext context = CreateContext(factory); + + var source = new DerivedMethodState(null) + { + NodeId = new NodeId("Start", 3), + SymbolicName = "Start", + BrowseName = new QualifiedName("Start", 3) + }; + source.CreateChild(context, new QualifiedName("Extra", 3)); + source.CreateOrReplaceInputArguments(context, null); + Assert.That(source.Extra, Is.Not.Null); + int handoutsAfterSource = factory.Handouts; + + var copy = new DerivedMethodState(null); + copy.Create(context, source); + + Assert.That(copy.Extra, Is.Not.Null, + "A four argument override on a derived type must still be reached."); + Assert.That(factory.Handouts, Is.EqualTo(handoutsAfterSource), + "An inherited assignment control capability must not let a four " + + "argument override consume identifiers."); + } + + /// + /// A type that intercepts by overriding the public CreateChild rather + /// than FindChild must still be dispatched through by a copy. + /// + [Test] + public void CopyStillDispatchesThroughCreateChildOverride() + { + SystemContext context = CreateContext(new CountingNodeIdFactory()); + + var source = new CreateChildOverrideState(null) + { + NodeId = new NodeId("Owner", 3), + SymbolicName = "Owner", + BrowseName = new QualifiedName("Owner", 3) + }; + var detail = new PropertyState(source) + { + NodeId = new NodeId("Owner_Detail", 3), + SymbolicName = "Detail", + BrowseName = new QualifiedName("Detail", 3), + ReferenceTypeId = ReferenceTypeIds.HasProperty + }; + source.AddChild(detail); + + var copy = new CreateChildOverrideState(null); + copy.Create(context, source); + + Assert.That(copy.CreateChildCalls, Is.GreaterThan(0), + "Overriding CreateChild must keep working across a node copy."); + } + + /// + /// Resolving a browse name no type in the hierarchy declares walks the + /// whole override chain. It must terminate. + /// + [Test] + public void FindingAnUndeclaredChildDoesNotRecurse() + { + SystemContext context = CreateContext(new CountingNodeIdFactory()); + var owner = new ModernOwnerState(null) + { + NodeId = new NodeId("Owner", 3), + SymbolicName = "Owner", + BrowseName = new QualifiedName("Owner", 3) + }; + + BaseInstanceState found = null; + Assert.DoesNotThrow( + () => found = owner.CreateChild( + context, new QualifiedName("NoSuchChild", 3), false)); + Assert.That(found, Is.Null); + } + + /// + /// Reading InputArguments must not return OutputArguments. + /// + [Test] + public void FindChildReturnsTheRequestedArgumentsProperty() + { + SystemContext context = CreateContext(new ChildIdFactory()); + MethodState method = CreateMethod("Start", 1); + PropertyState> inputs = + method.CreateOrReplaceInputArguments(context, null); + PropertyState> outputs = + method.CreateOrReplaceOutputArguments(context, null); + Assert.That(inputs.NodeId, Is.Not.EqualTo(outputs.NodeId)); + + BaseInstanceState found = method.FindChild( + context, new QualifiedName(BrowseNames.InputArguments, 3)); + + Assert.That(found, Is.SameAs(inputs), + "InputArguments must resolve to the input arguments property."); + } + [Test] public void CreateOrReplaceEnumStringsAssignsInstanceNodeIds() { diff --git a/tools/Opc.Ua.SourceGeneration.Core/Generators/NodeStateTemplates.cs b/tools/Opc.Ua.SourceGeneration.Core/Generators/NodeStateTemplates.cs index a59e03372d..b29a7fb770 100644 --- a/tools/Opc.Ua.SourceGeneration.Core/Generators/NodeStateTemplates.cs +++ b/tools/Opc.Ua.SourceGeneration.Core/Generators/NodeStateTemplates.cs @@ -1156,6 +1156,9 @@ public override void GetChildren( {{Tokens.ListOfCreateOrReplaceChild}} + /// + protected override bool SupportsInstanceNodeIdAssignmentControl => true; + /// protected override global::Opc.Ua.BaseInstanceState? FindChild( global::Opc.Ua.ISystemContext context, @@ -1167,6 +1170,9 @@ public override void GetChildren( { return null; } + // A caller that did not state its intent gets per instance + // NodeIds, which is what materialising onto a live tree wants. + bool assignInstanceNodeIds = true; global::Opc.Ua.BaseInstanceState? instance = null; switch (browseName.Name) @@ -1184,6 +1190,36 @@ public override void GetChildren( return base.FindChild(context, browseName, createOrReplace, replacement); } + /// + protected override global::Opc.Ua.BaseInstanceState? FindChild( + global::Opc.Ua.ISystemContext context, + global::Opc.Ua.QualifiedName browseName, + bool createOrReplace, + global::Opc.Ua.BaseInstanceState? replacement, + bool assignInstanceNodeIds) + { + if (browseName.IsNull) + { + return null; + } + global::Opc.Ua.BaseInstanceState? instance = null; + + switch (browseName.Name) + { + {{Tokens.ListOfFindChildCase}} + } + + if (instance != null) + + { + + return instance; + + } + return base.FindChild( + context, browseName, createOrReplace, replacement, assignInstanceNodeIds); + } + /// protected override void RemoveExplicitlyDefinedChild(global::Opc.Ua.BaseInstanceState child) { @@ -1210,7 +1246,8 @@ protected override void RemoveExplicitlyDefinedChild(global::Opc.Ua.BaseInstance case "{{Tokens.ChildBrowseNameLiteral}}": { instance = !createOrReplace ? - {{Tokens.ChildName}} : CreateOrReplace{{Tokens.ChildName}}(context, replacement); + {{Tokens.ChildName}} : CreateOrReplace{{Tokens.ChildName}}( + context, replacement, assignInstanceNodeIds); break; } From b662a6206d9302990c794f22ed3c48a247b4deb4 Mon Sep 17 00:00:00 2001 From: Marc Schier Date: Mon, 3 Aug 2026 16:27:00 +0200 Subject: [PATCH 2/5] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../Generators/NodeStateTemplates.cs | 3 --- 1 file changed, 3 deletions(-) diff --git a/tools/Opc.Ua.SourceGeneration.Core/Generators/NodeStateTemplates.cs b/tools/Opc.Ua.SourceGeneration.Core/Generators/NodeStateTemplates.cs index b29a7fb770..a762c0ee76 100644 --- a/tools/Opc.Ua.SourceGeneration.Core/Generators/NodeStateTemplates.cs +++ b/tools/Opc.Ua.SourceGeneration.Core/Generators/NodeStateTemplates.cs @@ -1210,11 +1210,8 @@ public override void GetChildren( } if (instance != null) - { - return instance; - } return base.FindChild( context, browseName, createOrReplace, replacement, assignInstanceNodeIds); From 26b572c858e260f71091cf497941e726779bebd9 Mon Sep 17 00:00:00 2001 From: Marc Date: Mon, 3 Aug 2026 17:52:53 +0200 Subject: [PATCH 3/5] Make NodeId assignment an argument of FindChild and CreateChild Collapse the assignment control overloads into the existing virtuals instead of layering a compatibility mechanism on top of them. NodeState.FindChild and NodeState.CreateChild now take assignInstanceNodeIds as their last parameter, defaulting to true. Call sites keep compiling and keep the 1.5.378 behaviour; overrides must add the parameter, which is the accepted breaking change for 2.0. With the request carried as an argument there is no override shape left that cannot be told, so SupportsInstanceNodeIdAssignmentControl and the NodeIdFactorySuppressedContext wrapper are gone - no context misreports its NodeIdFactory any more. The private FindDeclaredChild helpers only existed to keep the two overrides from recursing into each other and are inlined back into the single override. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: f45109d9-59cb-4ffd-a4f7-16e1ea449ae4 --- docs/NodeManagers.md | 40 +-- docs/migrate/2.0.x/node-states.md | 59 ++++ .../State/BaseDataVariableState.cs | 56 +--- src/Opc.Ua.Types/State/ISystemContext.cs | 9 +- src/Opc.Ua.Types/State/MethodState.cs | 52 +--- .../State/NodeIdFactorySuppressedContext.cs | 100 ------- src/Opc.Ua.Types/State/NodeState.cs | 139 ++------- .../Generators/NodeManagerGeneratorTests.cs | 42 +++ .../State/NodeInstanceExtensionsTests.cs | 279 ++++++------------ .../Generators/NodeStateTemplates.cs | 40 +-- 10 files changed, 257 insertions(+), 559 deletions(-) delete mode 100644 src/Opc.Ua.Types/State/NodeIdFactorySuppressedContext.cs diff --git a/docs/NodeManagers.md b/docs/NodeManagers.md index 4f848dd2e5..0318025a25 100644 --- a/docs/NodeManagers.md +++ b/docs/NodeManagers.md @@ -1601,36 +1601,42 @@ Notes: #### Custom node types and assignment control -`NodeState` carries a second `FindChild` overload that takes -`assignInstanceNodeIds`, plus a `SupportsInstanceNodeIdAssignmentControl` -property that states whether a type honours it. Source generated types -override both, so a copy of a generated node consumes nothing. - -Hand-written types that override only the four argument `FindChild` keep -working: for them the copy hides the `NodeIdFactory` for its duration, which -is the only channel that reaches an override with no such parameter. To take -the direct path instead, override both members: +`NodeState.FindChild` and `NodeState.CreateChild` carry +`assignInstanceNodeIds` as their last parameter. It defaults to `true`, so +callers that state no intent keep materialising children with per-instance +NodeIds. A type that declares children overrides `FindChild`, resolves the +ones it declares, and passes the request on — both to its +`CreateOrReplace` helpers and to the base: ```csharp -protected override bool SupportsInstanceNodeIdAssignmentControl => true; - protected override BaseInstanceState? FindChild( ISystemContext context, QualifiedName browseName, bool createOrReplace, BaseInstanceState? replacement, - bool assignInstanceNodeIds) + bool assignInstanceNodeIds = true) { - // ... thread assignInstanceNodeIds into your CreateOrReplace calls + if (browseName.Name == BrowseNames.EnumStrings) + { + return !createOrReplace + ? EnumStrings + : CreateOrReplaceEnumStrings(context, replacement, assignInstanceNodeIds); + } + return base.FindChild( context, browseName, createOrReplace, replacement, assignInstanceNodeIds); } ``` -Override the property only together with the five argument `FindChild`. A -hand-written type deriving from a generated one inherits `true`; if it -overrides only the four argument `FindChild` and needs that override to run -during a copy, it must override the property back to `false`. +Source generated types emit exactly this shape. Because the request is an +argument, every type — generated or hand-written — sees the real +`ISystemContext` during a copy; nothing wraps the context to hide the +`NodeIdFactory`. + +> **Breaking change in 2.0.** The four argument `FindChild` and the two +> argument `CreateChild` are gone. An override written against 1.5.378 fails +> to compile until the parameter is added; see the +> [migration guide](migrate/2.0.x/node-states.md#nodestate-findchild-and-createchild-state-nodeid-assignment). ### Current limitations diff --git a/docs/migrate/2.0.x/node-states.md b/docs/migrate/2.0.x/node-states.md index 80526f5f07..787f90ca65 100644 --- a/docs/migrate/2.0.x/node-states.md +++ b/docs/migrate/2.0.x/node-states.md @@ -113,6 +113,65 @@ protected override void OnAfterCreate(ISystemContext context, NodeState node, Ca } ``` +#### NodeState FindChild and CreateChild state NodeId assignment + +`NodeState.FindChild` and `NodeState.CreateChild` now take +`assignInstanceNodeIds` as their last parameter, and the old four argument +`FindChild` / two argument `CreateChild` virtuals are gone. The parameter +defaults to `true`, so **call sites keep compiling and keep the 1.5.378 +behaviour** — only overrides have to change: + +```csharp +// Before +protected override BaseInstanceState FindChild( + ISystemContext context, + QualifiedName browseName, + bool createOrReplace, + BaseInstanceState replacement) +{ + if (browseName.Name == BrowseNames.MyChild) + { + return createOrReplace + ? CreateOrReplaceMyChild(context, replacement) + : MyChild; + } + return base.FindChild(context, browseName, createOrReplace, replacement); +} + +// After — add the parameter and pass it on +protected override BaseInstanceState FindChild( + ISystemContext context, + QualifiedName browseName, + bool createOrReplace, + BaseInstanceState replacement, + bool assignInstanceNodeIds = true) +{ + if (browseName.Name == BrowseNames.MyChild) + { + return createOrReplace + ? CreateOrReplaceMyChild(context, replacement, assignInstanceNodeIds) + : MyChild; + } + return base.FindChild( + context, browseName, createOrReplace, replacement, assignInstanceNodeIds); +} +``` + +A missing override raises `CS0115` (`no suitable method found to +override`), so the compiler points at every site that needs the parameter. +Repeat the `= true` default in the override so callers bound to your derived +type keep the same behaviour. + +Why: a node copy creates each child and then initialises it from its source, +which overwrites any NodeId minted along the way. Passing +`assignInstanceNodeIds: false` — what `NodeState.Create(context, source)` +now does — stops the `ISystemContext.NodeIdFactory` from being asked for +identifiers that are immediately discarded, and leaked by factories that +track outstanding allocations. Thread the argument into every +`CreateOrReplace` call your override makes; source generated types +already do. See +[Custom node types and assignment control](../../NodeManagers.md#custom-node-types-and-assignment-control). + ### INodeManager3 - new role-permission and method-resolution hooks 2.0 introduces `INodeManager3`, an extension of `INodeManager2` that surfaces explicit hooks for per-role permission evaluation and for resolving the target of a `Call` request. `CustomNodeManager2` implements the new members with safe defaults that mirror the previous behavior, so node managers that already derive from `CustomNodeManager2` need no changes. diff --git a/src/Opc.Ua.Types/State/BaseDataVariableState.cs b/src/Opc.Ua.Types/State/BaseDataVariableState.cs index 6d259b8a6e..492feb2aa7 100644 --- a/src/Opc.Ua.Types/State/BaseDataVariableState.cs +++ b/src/Opc.Ua.Types/State/BaseDataVariableState.cs @@ -165,63 +165,13 @@ public override void GetChildren(ISystemContext context, IList - protected override bool SupportsInstanceNodeIdAssignmentControl => true; - - /// - /// Finds the child with the specified browse name. - /// - protected override BaseInstanceState? FindChild( - ISystemContext context, - QualifiedName browseName, - bool createOrReplace, - BaseInstanceState? replacement) - { - if (browseName.IsNull) - { - return null; - } - - return FindDeclaredChild(context, browseName, createOrReplace, replacement, true) - ?? base.FindChild(context, browseName, createOrReplace, replacement); - } - /// protected override BaseInstanceState? FindChild( ISystemContext context, QualifiedName browseName, bool createOrReplace, BaseInstanceState? replacement, - bool assignInstanceNodeIds) - { - if (browseName.IsNull) - { - return null; - } - - return FindDeclaredChild( - context, browseName, createOrReplace, replacement, assignInstanceNodeIds) - ?? base.FindChild( - context, browseName, createOrReplace, replacement, assignInstanceNodeIds); - } - - /// - /// Resolves the EnumStrings property declared by this type. - /// - /// The system context. - /// The browse name to resolve. - /// Whether a missing child is created. - /// The replacement to adopt, if any. - /// - /// Whether a newly created child may be given a per-instance NodeId. - /// - /// The child, or null when this type does not declare it. - private PropertyState>? FindDeclaredChild( - ISystemContext context, - QualifiedName browseName, - bool createOrReplace, - BaseInstanceState? replacement, - bool assignInstanceNodeIds) + bool assignInstanceNodeIds = true) { if (browseName.Name == BrowseNames.EnumStrings) { @@ -229,7 +179,9 @@ public override void GetChildren(ISystemContext context, IList diff --git a/src/Opc.Ua.Types/State/ISystemContext.cs b/src/Opc.Ua.Types/State/ISystemContext.cs index 10be515f23..02c6e006cf 100644 --- a/src/Opc.Ua.Types/State/ISystemContext.cs +++ b/src/Opc.Ua.Types/State/ISystemContext.cs @@ -90,12 +90,9 @@ public interface ISystemContext /// A factory that can be used to create node ids. /// /// - /// The node identifiers factory, or null when the context - /// suppresses NodeId assignment. Callers that assign NodeIds must - /// check for null; see - /// , which a node copy - /// uses so materialising its children does not consume identifiers - /// the copy immediately overwrites. + /// The node identifiers factory, or null when the context does + /// not assign NodeIds. Callers that assign NodeIds must check for + /// null. /// INodeIdFactory? NodeIdFactory { get; } diff --git a/src/Opc.Ua.Types/State/MethodState.cs b/src/Opc.Ua.Types/State/MethodState.cs index 03ab9438ab..795ff6d3fb 100644 --- a/src/Opc.Ua.Types/State/MethodState.cs +++ b/src/Opc.Ua.Types/State/MethodState.cs @@ -517,59 +517,13 @@ public override void GetChildren(ISystemContext context, IList - protected override bool SupportsInstanceNodeIdAssignmentControl => true; - - /// - protected override BaseInstanceState? FindChild( - ISystemContext context, - QualifiedName browseName, - bool createOrReplace, - BaseInstanceState? replacement) - { - if (browseName.IsNull) - { - return null; - } - return FindDeclaredChild(context, browseName, createOrReplace, replacement, true) - ?? base.FindChild(context, browseName, createOrReplace, replacement); - } - /// protected override BaseInstanceState? FindChild( ISystemContext context, QualifiedName browseName, bool createOrReplace, BaseInstanceState? replacement, - bool assignInstanceNodeIds) - { - if (browseName.IsNull) - { - return null; - } - return FindDeclaredChild( - context, browseName, createOrReplace, replacement, assignInstanceNodeIds) - ?? base.FindChild( - context, browseName, createOrReplace, replacement, assignInstanceNodeIds); - } - - /// - /// Resolves one of the arguments properties declared by this type. - /// - /// The system context. - /// The browse name to resolve. - /// Whether a missing child is created. - /// The replacement to adopt, if any. - /// - /// Whether a newly created child may be given a per-instance NodeId. - /// - /// The child, or null when this type does not declare it. - private PropertyState>? FindDeclaredChild( - ISystemContext context, - QualifiedName browseName, - bool createOrReplace, - BaseInstanceState? replacement, - bool assignInstanceNodeIds) + bool assignInstanceNodeIds = true) { switch (browseName.Name) { @@ -584,7 +538,9 @@ public override void GetChildren(ISystemContext context, IList - /// Forwards every member of a system context except its - /// , which is reported as absent. - /// - /// - /// Compatibility fallback for the node copy in - /// . A copy - /// materialises its children and then initialises each one from its source, - /// which overwrites whatever NodeId was assigned along the way, so assigning - /// one only consumes identifiers - and permanently leaks them for factories - /// that track outstanding allocations. - /// - /// A node type that reports - /// NodeState.SupportsInstanceNodeIdAssignmentControl is simply asked - /// not to assign, and never sees this wrapper. It exists only for types that - /// override the four argument FindChild and therefore have no way of - /// being told - hiding the factory is the one channel that reaches them. - /// Remove it once that overload is no longer supported. - /// - /// - internal sealed class NodeIdFactorySuppressedContext : ISystemContext - { - /// - /// Wraps the supplied context. - /// - /// The context to forward to. - public NodeIdFactorySuppressedContext(ISystemContext context) - { - m_context = context; - } - - /// - public object? SystemHandle => m_context.SystemHandle; - - /// - public string? UserId => m_context.UserId; - - /// - public ArrayOf PreferredLocales => m_context.PreferredLocales; - - /// - public string? AuditEntryId => m_context.AuditEntryId; - - /// - public NamespaceTable NamespaceUris => m_context.NamespaceUris; - - /// - public StringTable ServerUris => m_context.ServerUris; - - /// - public ITypeTable TypeTable => m_context.TypeTable; - - /// - public IEncodeableFactory EncodeableFactory => m_context.EncodeableFactory; - - /// - public INodeIdFactory? NodeIdFactory => null; - - /// - public NodeStateFactory NodeStateFactory => m_context.NodeStateFactory; - - /// - public ITelemetryContext Telemetry => m_context.Telemetry; - - private readonly ISystemContext m_context; - } -} diff --git a/src/Opc.Ua.Types/State/NodeState.cs b/src/Opc.Ua.Types/State/NodeState.cs index 6944bfa72a..3bbd6e9c88 100644 --- a/src/Opc.Ua.Types/State/NodeState.cs +++ b/src/Opc.Ua.Types/State/NodeState.cs @@ -337,21 +337,12 @@ protected virtual void Initialize(ISystemContext context, NodeState source) // Every child created below is initialized from its source right // afterwards, which overwrites the NodeId a factory would hand out - // here, so the copy must not consume identifiers for them. Types - // that understand the request are told not to assign; the rest have - // the factory hidden from them for the duration of the copy because - // their FindChild override has no way to be told. - bool suppressViaContext = !SupportsInstanceNodeIdAssignmentControl; - ISystemContext childContext = - suppressViaContext && children.Count > 0 && context.NodeIdFactory != null - ? new NodeIdFactorySuppressedContext(context) - : context; - + // here, so the copy must not consume identifiers for them. for (int ii = 0; ii < children.Count; ii++) { BaseInstanceState sourceChild = children[ii]; BaseInstanceState? child = CreateChild( - childContext, + context, sourceChild.BrowseName, assignInstanceNodeIds: false); @@ -645,31 +636,6 @@ public AccessRestrictionType? AccessRestrictions /// public bool DesignToolOnly { get; set; } - /// - /// Whether this type honours the assignInstanceNodeIds argument of - /// . - /// - /// - /// Source generated node types override this to true because they - /// thread the argument through to their CreateOrReplace<Child> - /// helpers. A hand written type that overrides only the four argument - /// FindChild leaves it false, and a node copy then keeps - /// dispatching through - /// and hides the - /// from it instead, so - /// identifiers are still not consumed for children whose NodeId is about - /// to be overwritten. Override this together with the five argument - /// FindChild, never on its own. - /// - /// A hand written type deriving from a source generated one inherits - /// true. That is safe - the factory is still hidden from any four - /// argument override reached from here - but such a type only sees the - /// children its generated base does not itself declare. Override this - /// back to false if the four argument override must run first. - /// - /// - protected virtual bool SupportsInstanceNodeIdAssignmentControl => false; - /// /// Exports a copy of the node to a node table. /// @@ -4720,57 +4686,31 @@ protected virtual ServiceResult WriteValueAttribute( /// /// Finds or creates the child with the specified browse name. /// - /// The context to use. - /// The browse name. - /// The child if available. Null otherwise. - public virtual BaseInstanceState? CreateChild( - ISystemContext context, - QualifiedName browseName) - { - if (browseName.IsNull) - { - return null; - } - - return FindChild(context, browseName, true, null); - } - - /// - /// Finds or creates the child with the specified browse name, stating - /// whether the child should be given a per-instance NodeId. - /// /// /// A caller that overwrites the child's NodeId immediately afterwards - - /// a node copy is the canonical case - passes false so the + /// a node copy is the canonical case - passes false for + /// so the /// is never asked for an - /// identifier that is about to be discarded. Only node types that report - /// honour the - /// request; for every other type this behaves exactly like - /// . + /// identifier that is about to be discarded. /// /// The context to use. /// The browse name. /// /// Whether a newly created child may be given a per-instance NodeId. + /// Defaults to true, which is what materialising a child onto a + /// live tree wants. /// /// The child if available. Null otherwise. public virtual BaseInstanceState? CreateChild( ISystemContext context, QualifiedName browseName, - bool assignInstanceNodeIds) + bool assignInstanceNodeIds = true) { if (browseName.IsNull) { return null; } - if (!SupportsInstanceNodeIdAssignmentControl) - { - // Keep dispatching through the original virtual so a type that - // overrides only that one is still the thing that runs. - return CreateChild(context, browseName); - } - return FindChild(context, browseName, true, null, assignInstanceNodeIds); } @@ -5425,22 +5365,17 @@ public virtual void GetReferences( } /// - /// Finds the child with the specified browse name, stating whether a - /// newly created child should be given a per-instance NodeId. + /// Finds the child with the specified browse name. /// /// - /// The base implementation forwards to - /// , - /// so a type that overrides only that method keeps working unchanged. It - /// hides the while doing so - /// when assignment was declined, because an override with no such - /// parameter cannot be told any other way. - /// - /// A type that honours the request overrides this method and reports - /// as true, - /// which is what lets a node copy decline assignment outright while - /// still showing that type the real context. - /// + /// A type that declares children overrides this method, resolves the + /// ones it declares, and forwards everything else to the base. It must + /// pass on to every + /// CreateOrReplace<Child> helper it calls: a caller that + /// overwrites the child's NodeId immediately afterwards - a node copy is + /// the canonical case - declines assignment so the + /// is never asked for an + /// identifier that is about to be discarded. /// /// The context for the system being accessed. /// The browse name of the children to add. @@ -5448,9 +5383,13 @@ public virtual void GetReferences( /// not exist then the child is created or replaced with the provided /// replacement. /// The replacement to use if createOrReplace is - /// true. + /// true. If not of same type, the node state is used to initialize a new + /// instance of the required type (for narrowing conversation to the type + /// definition /// /// Whether a newly created child may be given a per-instance NodeId. + /// Defaults to true, which is what materialising a child onto a + /// live tree wants. /// /// The child. protected virtual BaseInstanceState? FindChild( @@ -5458,39 +5397,7 @@ public virtual void GetReferences( QualifiedName browseName, bool createOrReplace, BaseInstanceState? replacement, - bool assignInstanceNodeIds) - { - // Control passes here to hand off to an override that has no way of - // being told not to assign - either because the type predates this - // overload, or because a derived type only overrides the four - // argument one. Hiding the factory is the only channel that reaches - // it, so a request not to assign is honoured even then. - ISystemContext childContext = - !assignInstanceNodeIds && context.NodeIdFactory != null - ? new NodeIdFactorySuppressedContext(context) - : context; - - return FindChild(childContext, browseName, createOrReplace, replacement); - } - - /// - /// Finds the child with the specified browse name. - /// - /// The context for the system being accessed. - /// The browse name of the children to add. - /// if set to true and the child does - /// not exist then the child is created or replaced with the provided - /// replacement. - /// The replacement to use if createOrReplace is - /// true. If not of same type, the node state is used to initialize a new - /// instance of the required type (for narrowing conversation to the type - /// definition - /// The child. - protected virtual BaseInstanceState? FindChild( - ISystemContext context, - QualifiedName browseName, - bool createOrReplace, - BaseInstanceState? replacement) + bool assignInstanceNodeIds = true) { if (browseName.IsNull) { diff --git a/tests/Opc.Ua.SourceGeneration.Core.Tests/Generators/NodeManagerGeneratorTests.cs b/tests/Opc.Ua.SourceGeneration.Core.Tests/Generators/NodeManagerGeneratorTests.cs index 4566dca936..e0aa7cac37 100644 --- a/tests/Opc.Ua.SourceGeneration.Core.Tests/Generators/NodeManagerGeneratorTests.cs +++ b/tests/Opc.Ua.SourceGeneration.Core.Tests/Generators/NodeManagerGeneratorTests.cs @@ -267,6 +267,48 @@ public void CreateOrReplaceChildAssignsInstanceNodeIdsByDefault() }); } + /// + /// Every FindChild override a generated type emits carries the + /// assignment argument with its true default: the caller states + /// its intent, so no second overload, capability property or context + /// wrapper is needed to reach the override. + /// + [Test] + public void GeneratedTypesEmitOneFindChildOverrideCarryingTheAssignmentFlag() + { + Dictionary files = GenerateForTestModel(generateNodeManager: false); + string source = files.Values.Single(value => + value.Contains(" CreateOrReplaceRed(", StringComparison.Ordinal)); + + const string signature = + "protected override global::Opc.Ua.BaseInstanceState? FindChild("; + var parameterLists = new List(); + for (int index = source.IndexOf(signature, StringComparison.Ordinal); + index >= 0; + index = source.IndexOf(signature, index + signature.Length, StringComparison.Ordinal)) + { + int start = index + signature.Length; + int end = source.IndexOf(')', start); + parameterLists.Add(source.Substring(start, end - start)); + } + + Assert.Multiple(() => + { + Assert.That(parameterLists, Is.Not.Empty, + "A type declaring children must resolve them in a FindChild override."); + Assert.That(parameterLists, Has.All.Contains("bool assignInstanceNodeIds = true"), + "Every override must carry the argument, and repeat the default so " + + "callers keep the 1.5.378 behaviour."); + Assert.That(source, Does.Not.Contain("SupportsInstanceNodeIdAssignmentControl"), + "Assignment control is stated per call, not advertised per type."); + Assert.That(source, Does.Contain("return base.FindChild("), + "An unmatched browse name must pass the request on to the base."); + Assert.That(source, Does.Contain( + "context, browseName, createOrReplace, replacement, assignInstanceNodeIds);"), + "The request must be forwarded verbatim to the base."); + }); + } + /// /// The generated factories build declaration subtrees whose NodeIds /// must stay at their type-level values - the enclosing diff --git a/tests/Opc.Ua.Types.Tests/State/NodeInstanceExtensionsTests.cs b/tests/Opc.Ua.Types.Tests/State/NodeInstanceExtensionsTests.cs index f6be3d37cf..3132710356 100644 --- a/tests/Opc.Ua.Types.Tests/State/NodeInstanceExtensionsTests.cs +++ b/tests/Opc.Ua.Types.Tests/State/NodeInstanceExtensionsTests.cs @@ -102,13 +102,13 @@ public NodeId New(ISystemContext context, NodeState node) } /// - /// A hand written type that declares a child through the four argument - /// FindChild only, standing in for a custom node manager written - /// before the assignment control overload existed. + /// A hand written type that declares a child, standing in for a custom + /// node manager. It threads the assignment request into the child it + /// materialises, which is what every node type is now expected to do. /// - private sealed class LegacyOwnerState : BaseObjectState + private sealed class CustomOwnerState : BaseObjectState { - public LegacyOwnerState(NodeState parent) + public CustomOwnerState(NodeState parent) : base(parent) { } @@ -117,8 +117,8 @@ public LegacyOwnerState(NodeState parent) /// /// Whether a NodeIdFactory was visible the last time a child was - /// created. A copy hides it from this type, because there is no way - /// to tell a four argument override not to assign. + /// created. Every type is asked not to assign rather than shown a + /// context that misreports the factory, so this stays true. /// public bool SawNodeIdFactory { get; private set; } @@ -137,37 +137,40 @@ protected override BaseInstanceState FindChild( ISystemContext context, QualifiedName browseName, bool createOrReplace, - BaseInstanceState replacement) + BaseInstanceState replacement, + bool assignInstanceNodeIds = true) { - if (browseName.Name == "Detail") + if (browseName.Name != "Detail") + { + return base.FindChild( + context, browseName, createOrReplace, replacement, + assignInstanceNodeIds); + } + if (!createOrReplace) { - if (!createOrReplace) - { - return Detail; - } - SawNodeIdFactory = context.NodeIdFactory != null; - Detail ??= new PropertyState(this) - { - SymbolicName = "Detail", - BrowseName = new QualifiedName("Detail", 3), - ReferenceTypeId = ReferenceTypeIds.HasProperty - }; - if (context.NodeIdFactory != null && Detail.NodeId.IsNull) - { - Detail.NodeId = context.NodeIdFactory.New(context, Detail); - } return Detail; } - return base.FindChild(context, browseName, createOrReplace, replacement); + SawNodeIdFactory = context.NodeIdFactory != null; + Detail ??= new PropertyState(this) + { + SymbolicName = "Detail", + BrowseName = new QualifiedName("Detail", 3), + ReferenceTypeId = ReferenceTypeIds.HasProperty + }; + if (assignInstanceNodeIds && + context.NodeIdFactory != null && + Detail.NodeId.IsNull) + { + Detail.NodeId = context.NodeIdFactory.New(context, Detail); + } + return Detail; } } /// - /// A hand written type deriving from a type that already opts into - /// assignment control, overriding only the four argument - /// FindChild. It inherits the capability as true, so the - /// copy does not wrap the context up front - the factory must still be - /// hidden by the time control reaches this override. + /// A hand written type deriving from a type that declares children of + /// its own. Its override must be reached by a copy, and must decline + /// assignment for the child it adds on top. /// private sealed class DerivedMethodState : MethodState { @@ -193,27 +196,32 @@ protected override BaseInstanceState FindChild( ISystemContext context, QualifiedName browseName, bool createOrReplace, - BaseInstanceState replacement) + BaseInstanceState replacement, + bool assignInstanceNodeIds = true) { - if (browseName.Name == "Extra") + if (browseName.Name != "Extra") + { + return base.FindChild( + context, browseName, createOrReplace, replacement, + assignInstanceNodeIds); + } + if (!createOrReplace) { - if (!createOrReplace) - { - return Extra; - } - Extra ??= new PropertyState(this) - { - SymbolicName = "Extra", - BrowseName = new QualifiedName("Extra", 3), - ReferenceTypeId = ReferenceTypeIds.HasProperty - }; - if (context.NodeIdFactory != null && Extra.NodeId.IsNull) - { - Extra.NodeId = context.NodeIdFactory.New(context, Extra); - } return Extra; } - return base.FindChild(context, browseName, createOrReplace, replacement); + Extra ??= new PropertyState(this) + { + SymbolicName = "Extra", + BrowseName = new QualifiedName("Extra", 3), + ReferenceTypeId = ReferenceTypeIds.HasProperty + }; + if (assignInstanceNodeIds && + context.NodeIdFactory != null && + Extra.NodeId.IsNull) + { + Extra.NodeId = context.NodeIdFactory.New(context, Extra); + } + return Extra; } } @@ -231,101 +239,12 @@ public CreateChildOverrideState(NodeState parent) public int CreateChildCalls { get; private set; } public override BaseInstanceState CreateChild( - ISystemContext context, - QualifiedName browseName) - { - CreateChildCalls++; - return base.CreateChild(context, browseName); - } - } - - /// - /// A hand written type that opts into assignment control, standing in - /// for what the source generator now emits. - /// - private sealed class ModernOwnerState : BaseObjectState - { - public ModernOwnerState(NodeState parent) - : base(parent) - { - } - - public PropertyState Detail { get; private set; } - - /// - /// Whether a NodeIdFactory was visible the last time a child was - /// created. This type is asked not to assign, so it keeps seeing - /// the real context. - /// - public bool SawNodeIdFactory { get; private set; } - - protected override bool SupportsInstanceNodeIdAssignmentControl => true; - - public override void GetChildren( - ISystemContext context, - IList children) - { - if (Detail != null) - { - children.Add(Detail); - } - base.GetChildren(context, children); - } - - protected override BaseInstanceState FindChild( - ISystemContext context, - QualifiedName browseName, - bool createOrReplace, - BaseInstanceState replacement) - { - // Never forward to the five argument overload: its unmatched - // path returns here through the base and would recurse forever. - return FindDeclaredChild(context, browseName, createOrReplace, true) - ?? base.FindChild(context, browseName, createOrReplace, replacement); - } - - protected override BaseInstanceState FindChild( ISystemContext context, QualifiedName browseName, - bool createOrReplace, - BaseInstanceState replacement, - bool assignInstanceNodeIds) + bool assignInstanceNodeIds = true) { - return FindDeclaredChild( - context, browseName, createOrReplace, assignInstanceNodeIds) - ?? base.FindChild( - context, browseName, createOrReplace, replacement, - assignInstanceNodeIds); - } - - private PropertyState FindDeclaredChild( - ISystemContext context, - QualifiedName browseName, - bool createOrReplace, - bool assignInstanceNodeIds) - { - if (browseName.Name != "Detail") - { - return null; - } - if (!createOrReplace) - { - return Detail; - } - SawNodeIdFactory = context.NodeIdFactory != null; - Detail ??= new PropertyState(this) - { - SymbolicName = "Detail", - BrowseName = new QualifiedName("Detail", 3), - ReferenceTypeId = ReferenceTypeIds.HasProperty - }; - if (assignInstanceNodeIds && - context.NodeIdFactory != null && - Detail.NodeId.IsNull) - { - Detail.NodeId = context.NodeIdFactory.New(context, Detail); - } - return Detail; + CreateChildCalls++; + return base.CreateChild(context, browseName, assignInstanceNodeIds); } } @@ -602,7 +521,7 @@ public void CreateOrReplaceArgumentsHonoursTheAssignmentOptOut() /// track outstanding allocations, leaks - them. /// [Test] - public void CopyOfTypeWithAssignmentControlConsumesNoNodeIds() + public void CopyOfDeclaringTypeConsumesNoNodeIds() { var factory = new CountingNodeIdFactory(); SystemContext context = CreateContext(factory); @@ -625,7 +544,7 @@ public void CopyOfTypeWithAssignmentControlConsumesNoNodeIds() /// nothing, otherwise the optimisation would have changed behaviour. /// [Test] - public void CopyOfTypeWithAssignmentControlReproducesTheSource() + public void CopyOfDeclaringTypeReproducesTheSource() { var factory = new CountingNodeIdFactory(); SystemContext context = CreateContext(factory); @@ -644,17 +563,16 @@ public void CopyOfTypeWithAssignmentControlReproducesTheSource() } /// - /// A type that predates the assignment control overload still reaches - /// its own four argument FindChild during a copy, and the factory is - /// hidden from it so it cannot consume identifiers either. + /// A hand written type is asked not to assign during a copy, and the + /// factory it is shown is the real one - nothing misreports the context. /// [Test] - public void CopyOfLegacyTypeStillConsumesNoNodeIds() + public void CopyOfCustomTypeConsumesNoNodeIds() { var factory = new CountingNodeIdFactory(); SystemContext context = CreateContext(factory); - var source = new LegacyOwnerState(null) + var source = new CustomOwnerState(null) { NodeId = new NodeId("Owner", 3), SymbolicName = "Owner", @@ -664,80 +582,78 @@ public void CopyOfLegacyTypeStillConsumesNoNodeIds() Assert.That(source.Detail, Is.Not.Null); int handoutsAfterSource = factory.Handouts; - var copy = new LegacyOwnerState(null); + var copy = new CustomOwnerState(null); copy.Create(context, source); Assert.That(copy.Detail, Is.Not.Null, - "The legacy four argument override must still be reached by a copy."); + "A hand written override must still be reached by a copy."); Assert.That(factory.Handouts, Is.EqualTo(handoutsAfterSource), - "The factory stays hidden from types that cannot be told not to assign."); + "The type was asked not to assign, so no identifier may be consumed."); } /// - /// The fallback works by hiding the factory, which is why it is only - /// used for types that cannot be told not to assign. + /// Declining assignment is stated as an argument, so every type keeps + /// seeing the real system context - no wrapper reports the factory as + /// absent. /// [Test] - public void CopyOfLegacyTypeHidesTheNodeIdFactory() + public void CopySeesTheRealContext() { var factory = new CountingNodeIdFactory(); SystemContext context = CreateContext(factory); - var source = new LegacyOwnerState(null) + var source = new CustomOwnerState(null) { NodeId = new NodeId("Owner", 3), SymbolicName = "Owner", BrowseName = new QualifiedName("Owner", 3) }; source.CreateChild(context, new QualifiedName("Detail", 3)); + int handoutsAfterSource = factory.Handouts; - var copy = new LegacyOwnerState(null); + var copy = new CustomOwnerState(null); copy.Create(context, source); - Assert.That(copy.SawNodeIdFactory, Is.False, - "A legacy override can only be stopped by hiding the factory from it."); + Assert.That(copy.Detail, Is.Not.Null); + Assert.That(copy.SawNodeIdFactory, Is.True, + "The context must not misreport the factory to a type that " + + "understands the assignment request."); + Assert.That(factory.Handouts, Is.EqualTo(handoutsAfterSource), + "It was asked not to assign, so no identifier may be consumed."); } /// - /// A type that opts into assignment control is simply asked not to - /// assign, so it keeps seeing the real context. This is the point of - /// the overload: no wrapper misreports the factory as absent. + /// Callers that state no intent keep the 1.5.378 behaviour: the default + /// of the assignment argument is true. /// [Test] - public void CopyOfTypeWithAssignmentControlSeesTheRealContext() + public void CreateChildAssignsInstanceNodeIdsByDefault() { var factory = new CountingNodeIdFactory(); SystemContext context = CreateContext(factory); - var source = new ModernOwnerState(null) + var owner = new CustomOwnerState(null) { NodeId = new NodeId("Owner", 3), SymbolicName = "Owner", BrowseName = new QualifiedName("Owner", 3) }; - source.CreateChild(context, new QualifiedName("Detail", 3)); - int handoutsAfterSource = factory.Handouts; - var copy = new ModernOwnerState(null); - copy.Create(context, source); + BaseInstanceState detail = owner.CreateChild( + context, new QualifiedName("Detail", 3)); - Assert.That(copy.Detail, Is.Not.Null); - Assert.That(copy.SawNodeIdFactory, Is.True, - "The context must not misreport the factory to a type that " + - "understands the assignment request."); - Assert.That(factory.Handouts, Is.EqualTo(handoutsAfterSource), - "It was asked not to assign, so no identifier may be consumed."); + Assert.That(detail, Is.Not.Null); + Assert.That(detail.NodeId.IsNull, Is.False, + "A caller that states no intent must still get a per-instance NodeId."); + Assert.That(factory.Handouts, Is.EqualTo(1)); } /// - /// A type deriving from one that opts into assignment control inherits - /// the capability, so the copy shows it the real context. Its four - /// argument override cannot be told to decline, so the factory must be - /// hidden by the time control reaches it - otherwise the inherited - /// true would quietly reintroduce the leak. + /// A type deriving from one that declares children of its own must be + /// reached by a copy, and must not consume identifiers either. /// [Test] - public void CopyOfDerivedLegacyOverrideConsumesNoNodeIds() + public void CopyOfDerivedCustomOverrideConsumesNoNodeIds() { var factory = new CountingNodeIdFactory(); SystemContext context = CreateContext(factory); @@ -757,10 +673,11 @@ public void CopyOfDerivedLegacyOverrideConsumesNoNodeIds() copy.Create(context, source); Assert.That(copy.Extra, Is.Not.Null, - "A four argument override on a derived type must still be reached."); + "An override on a derived type must still be reached."); + Assert.That(copy.InputArguments, Is.Not.Null, + "The children the base type declares must be copied as well."); Assert.That(factory.Handouts, Is.EqualTo(handoutsAfterSource), - "An inherited assignment control capability must not let a four " + - "argument override consume identifiers."); + "A derived override must decline assignment just like its base."); } /// @@ -802,7 +719,7 @@ public void CopyStillDispatchesThroughCreateChildOverride() public void FindingAnUndeclaredChildDoesNotRecurse() { SystemContext context = CreateContext(new CountingNodeIdFactory()); - var owner = new ModernOwnerState(null) + var owner = new CustomOwnerState(null) { NodeId = new NodeId("Owner", 3), SymbolicName = "Owner", diff --git a/tools/Opc.Ua.SourceGeneration.Core/Generators/NodeStateTemplates.cs b/tools/Opc.Ua.SourceGeneration.Core/Generators/NodeStateTemplates.cs index a762c0ee76..18067c8305 100644 --- a/tools/Opc.Ua.SourceGeneration.Core/Generators/NodeStateTemplates.cs +++ b/tools/Opc.Ua.SourceGeneration.Core/Generators/NodeStateTemplates.cs @@ -1156,52 +1156,14 @@ public override void GetChildren( {{Tokens.ListOfCreateOrReplaceChild}} - /// - protected override bool SupportsInstanceNodeIdAssignmentControl => true; - - /// - protected override global::Opc.Ua.BaseInstanceState? FindChild( - global::Opc.Ua.ISystemContext context, - global::Opc.Ua.QualifiedName browseName, - bool createOrReplace, - global::Opc.Ua.BaseInstanceState? replacement) - { - if (browseName.IsNull) - { - return null; - } - // A caller that did not state its intent gets per instance - // NodeIds, which is what materialising onto a live tree wants. - bool assignInstanceNodeIds = true; - global::Opc.Ua.BaseInstanceState? instance = null; - - switch (browseName.Name) - { - {{Tokens.ListOfFindChildCase}} - } - - if (instance != null) - - { - - return instance; - - } - return base.FindChild(context, browseName, createOrReplace, replacement); - } - /// protected override global::Opc.Ua.BaseInstanceState? FindChild( global::Opc.Ua.ISystemContext context, global::Opc.Ua.QualifiedName browseName, bool createOrReplace, global::Opc.Ua.BaseInstanceState? replacement, - bool assignInstanceNodeIds) + bool assignInstanceNodeIds = true) { - if (browseName.IsNull) - { - return null; - } global::Opc.Ua.BaseInstanceState? instance = null; switch (browseName.Name) From 65d1554f0f3393a6d9a11233d6ed8f4e88dafbc3 Mon Sep 17 00:00:00 2001 From: Marc Date: Mon, 3 Aug 2026 17:55:44 +0200 Subject: [PATCH 4/5] Add the FindChild and CreateChild breaking change to the migration guide Note the reduced NodeIdFactory call count during a node copy as a behaviour change, and point at the node-states sub-doc for the override migration. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: f45109d9-59cb-4ffd-a4f7-16e1ea449ae4 --- docs/MigrationGuide.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/docs/MigrationGuide.md b/docs/MigrationGuide.md index 32c8bb45e2..73dc35f321 100644 --- a/docs/MigrationGuide.md +++ b/docs/MigrationGuide.md @@ -43,6 +43,30 @@ Looking for the broader narrative (non-prescriptive overview of what changed in a release)? See [What's New in 2.0](WhatsNewIn2.0.md). +## Migrating node types that override FindChild or CreateChild + +`NodeState.FindChild` and `NodeState.CreateChild` take +`assignInstanceNodeIds` as their last parameter, and the four argument +`FindChild` / two argument `CreateChild` virtuals are gone. The parameter +defaults to `true`, so **call sites are unaffected**; an override fails to +compile (`CS0115`) until the parameter is added and passed on. + +Behaviour note: a node copy — `NodeState.Create(context, source)` and the +`Initialize(ISystemContext, NodeState)` path behind it — now passes +`assignInstanceNodeIds: false`. It no longer asks +`ISystemContext.NodeIdFactory` for identifiers that the copy overwrites +from the source on the very next statement. If your `INodeIdFactory` +counts, reserves or audits every allocation, expect **fewer** calls than in +1.5.378 for the same address space; the resulting NodeIds are unchanged. +Any `NodeState` subclass you own must thread the argument into its +`CreateOrReplace` calls to get that benefit. + +See +[Node states § FindChild and CreateChild](migrate/2.0.x/node-states.md#nodestate-findchild-and-createchild-state-nodeid-assignment) +for the before/after and +[Custom node types and assignment control](NodeManagers.md#custom-node-types-and-assignment-control) +for the runtime rules. + ## Migrating servers that relied on unserved history advertisement Server startup now reconciles variables that advertise history with the From 25842d49cbad569a9e8031650c19e21b281ab417 Mon Sep 17 00:00:00 2001 From: Marc Date: Mon, 3 Aug 2026 17:57:14 +0200 Subject: [PATCH 5/5] Index the FindChild and CreateChild change in the 2.0.x migration README Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: f45109d9-59cb-4ffd-a4f7-16e1ea449ae4 --- docs/migrate/2.0.x/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/migrate/2.0.x/README.md b/docs/migrate/2.0.x/README.md index 777d1932af..23798ce64c 100644 --- a/docs/migrate/2.0.x/README.md +++ b/docs/migrate/2.0.x/README.md @@ -34,7 +34,7 @@ table; loading a single sub-doc keeps the context window small. | `OPCFoundation.NetStandard.Opc.Ua.*` package upgrade, TFM changes, Newtonsoft removal | [`packages.md`](packages.md) | | Source-generated `*Collection` shims, NodeManager generator, default of boolean properties, project structure | [`source-generation.md`](source-generation.md) | | `IEncodeableFactoryBuilder`, `IType`, JSON / XML / binary encoders, `EncodeableFactory.GlobalFactory`, `IJsonEncodeable`, `ComplexTypes` namespace move | [`encoders.md`](encoders.md) | -| `CustomNodeManager`, `NodeState` clone / read / write helpers, `OnAfterCreate(CancellationToken)`, `INodeManager3`, `INodeCache.InvalidateNode`, generics on `BaseVariableState` / `BaseVariableTypeState`, predefined-node processing | [`node-states.md`](node-states.md) | +| `CustomNodeManager`, `NodeState` clone / read / write helpers, `OnAfterCreate(CancellationToken)`, `FindChild` / `CreateChild` NodeId assignment, `INodeManager3`, `INodeCache.InvalidateNode`, generics on `BaseVariableState` / `BaseVariableTypeState`, predefined-node processing | [`node-states.md`](node-states.md) | | `IUserIdentityTokenHandler`, `IClientIdentityProvider`, `IUserTokenAuthenticator`, `IAccessTokenProvider`, `ITokenIssuer`, `IIdentityClaims`, caller-supplied secrets, secret store | [`identity.md`](identity.md) | | `CertificateValidator`, ref-counted `Certificate` wrapper, `CertificateManager`, `ICertificateProvider`, obsoleted `X509Certificate2` direct-exposure APIs, PushManagement transactions (`ApplyChanges`-gated TrustList updates) | [`certificates.md`](certificates.md) | | `ApplicationConfiguration` changes, Data-Contract serializer removal, `ParseExtension` / `UpdateExtension` signature, session / browser state persistence | [`configuration.md`](configuration.md) |