Assign instance NodeIds in generated CreateOrReplace<Child> helpers - #4104
Conversation
…4099) The generated CreateInstanceOf<Type> factories rebase a materialised subtree onto per-instance NodeIds, but <Owner>.CreateOrReplace<Child> - the plumbing behind NodeState.CreateChild / ReplaceChild - did not. Any node manager mixing the two had to hand-roll a recursive re-stamp before registration or sibling instances collided on NodeIds. CreateOrReplace<Child> now takes an optional assignInstanceNodeIds flag (default true). When the context supplies a NodeIdFactory, a child whose NodeId is null or still equal to its declaration NodeId - and its descendants - are rebased through AssignInstanceNodeId / AssignInstanceChildNodeIds. A NodeId the caller already assigned is never overwritten. The helper also stamps the new child's SymbolicName, BrowseName and DisplayName: without an identity a path-based INodeIdFactory derives the same id for every child of a parent, so the assignment would have introduced collisions rather than removed them. The generated Create* factories pass assignInstanceNodeIds: false. They build declaration subtrees whose NodeIds must stay at their type-level values, and the enclosing CreateInstanceOf<Type> factory rebases the finished subtree in a single pass. Gating on "the owner already has an instance NodeId" instead is not viable: the child factories run on declaration instances (CreatePumpType_Operational executes on a FunctionalGroupState whose NodeId is ObjectIds.PumpType_Operational, not ObjectTypeIds.FunctionalGroupType), so such a guard would fire while the type template is being built and rewrite declaration NodeIds. CreateInstanceOf<Type> no longer requires a non-null parent to rebase - supplying a browseName is what marks a dynamically materialised instance. This lets PumpDeviceIntegrationServer pass the real DeviceSet parent and drop its manual AssignChildNodeIds walk. The hand-written twins MethodState.CreateOrReplace{Input,Output}Arguments and BaseDataVariableState.CreateOrReplaceEnumStrings gain the same parameter and behaviour, sharing a new internal NodeInstanceExtensions.AssignNewChildInstanceNodeIds. This is also required so the generated "public new" overrides keep hiding a matching base signature. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b019f3a2-f58d-4f7e-93db-c8a0dec656e2
Both subscriptions are handed to the session through session.AddSubscription, and Session.DisposeAsyncCore disposes every subscription it holds, so this is an ownership transfer CA2000 cannot infer. Wrap the two constructions in the pragma pair the repository already uses for this pattern (see RedundantClientSessionBuilder.Build). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b019f3a2-f58d-4f7e-93db-c8a0dec656e2
There was a problem hiding this comment.
Pull request overview
This PR improves runtime instance materialization consistency by ensuring source-generated CreateOrReplace<Child> helpers (used by NodeState.CreateChild/ReplaceChild) can assign per-instance NodeIds and stamp child identity, aligning behavior with CreateInstanceOf<Type> and removing the need for manual re-stamping in node managers (e.g., Pump sample). It also adds tests and updates documentation/migration guidance; plus a small build-hygiene change for CA2000 in the console client sample.
Changes:
- Extend generated
CreateOrReplace<Child>helpers withassignInstanceNodeIds(defaulttrue) and per-instance NodeId rebasing logic; update generator call sites to opt out in type/declaration factories. - Add shared runtime helper
NodeInstanceExtensions.AssignNewChildInstanceNodeIdsand apply it to handwrittenMethodStateandBaseDataVariableStateCreateOrReplace*helpers. - Update sample + docs/migration guidance to rely on the new NodeId assignment behavior (and add targeted unit tests).
Reviewed changes
Copilot reviewed 13 out of 13 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| tools/Opc.Ua.SourceGeneration.Core/Generators/NodeStateTemplates.cs | Updates generated CreateOrReplace<Child> template to stamp identity + optionally assign per-instance NodeIds. |
| tools/Opc.Ua.SourceGeneration.Core/Generators/NodeStateGenerator.cs | Ensures generator call sites opt out of assignment when building declaration/type subtrees; adds child declaration NodeId constant for comparisons. |
| src/Opc.Ua.Types/State/NodeInstanceExtensions.cs | Adds shared helper to assign per-instance NodeIds for newly materialized children. |
| src/Opc.Ua.Types/State/MethodState.cs | Adds opt-in/out parameter and uses shared helper for per-instance NodeId assignment of argument properties. |
| src/Opc.Ua.Types/State/BaseDataVariableState.cs | Adds opt-in/out parameter and uses shared helper for per-instance NodeId assignment of EnumStrings. |
| tests/Opc.Ua.Types.Tests/State/NodeInstanceExtensionsTests.cs | Adds coverage for new per-instance NodeId assignment behavior and opt-out/caller-assigned cases. |
| tests/Opc.Ua.SourceGeneration.Core.Tests/Generators/NodeManagerGeneratorTests.cs | Adds generator-output assertions for default assignment + type-factory opt-out + CreateInstanceOf guard change. |
| tests/Opc.Ua.SourceGeneration.Core.Tests/Generators/NodeStateGeneratorTests.cs | Updates expected generated call sites to include assignInstanceNodeIds: false. |
| samples/PumpDeviceIntegrationServer/PumpNodeManager.cs | Removes manual recursive NodeId re-stamping; passes the real parent into CreateInstanceOfPumpType. |
| samples/ConsoleReferenceClient/ClientSamples.cs | Adds targeted CA2000 suppression around subscription allocation with rationale. |
| docs/SourceGeneratedNodeManagers.md | Documents runtime instance NodeId assignment behavior and helper families. |
| docs/migrate/2.0.x/node-states.md | Adds migration note explaining changed CreateChild/ReplaceChild behavior and removal of manual re-stamping. |
| docs/DeviceIntegration.md | Refreshes narrative to reflect new instance rebasing behavior. |
Comments suppressed due to low confidence (1)
tools/Opc.Ua.SourceGeneration.Core/Generators/NodeStateTemplates.cs:1248
- When
replacementis already of the target child type (typedReplacement), the code adopts it directly but does not stampSymbolicName/BrowseName/DisplayName. This undermines the new per-instance NodeId assignment for path-basedNodeIdFactoryimplementations (a bare typed replacement will still collide) and contradicts the stated intent that the helper stamps the new child's identity.
if (replacement is {{Tokens.ClassName}} typedReplacement)
{
// a replacement of the matching type is used directly,
// replacing any child that may already exist.
{{Tokens.ChildName}} = typedReplacement;
}
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #4104 +/- ##
===========================================
+ Coverage 62.58% 79.87% +17.29%
===========================================
Files 1403 1514 +111
Lines 190439 209245 +18806
Branches 33322 36008 +2686
===========================================
+ Hits 119184 167139 +47955
+ Misses 59788 29568 -30220
- Partials 11467 12538 +1071
🚀 New features to boost your workflow:
|
…tests Two review comments on #4104: * docs/migrate/2.0.x/node-states.md - removed the CreateChild/ReplaceChild section. The source generators are new in 2.0, so their NodeId behaviour is not a migration topic. The behaviour stays documented in docs/SourceGeneratedNodeManagers.md. * Added PumpInstanceNodeIdRegressionTests covering the pump DI server: - the full set of 77 model identifiers a configured pump materialises, captured from the manager before this change so a child that stops being materialised fails the test; - a second pump exposes the identical generated model surface, minus what the fluent configuration adds on top; - every NodeId the generated helpers assign is minted by PumpNodeManager.New ({parentIdentifier}_{symbolicName} in the parent's namespace) rather than being a type-level declaration id; - no NodeId is shared between two instances of the type; - the generated factory already carries per-instance NodeIds before registration, so a regression is not masked by the defensive rebase AsyncCustomNodeManager.AddPredefinedNodeAsync performs; - the alarm subtree the fluent builder attaches keeps standard declaration NodeIds - a pre-existing gap outside the generated instance helpers, pinned so it cannot drift unnoticed. The 77-identifier baseline was captured by running the same walk against the pre-change commit; the resulting node set and every NodeId are identical before and after. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b019f3a2-f58d-4f7e-93db-c8a0dec656e2
The XML docs on CreateOrReplace<Child> state that a null replacement
creates a new child, and the body branches on replacement != null, but
the emitted signature declared a non-nullable BaseInstanceState. In a
nullable-enabled consumer that advertises the wrong contract and makes
the documented CreateOrReplace<Child>(context, null) call produce CS8625.
The hand-written twins (MethodState.CreateOrReplace{Input,Output}Arguments,
BaseDataVariableState.CreateOrReplaceEnumStrings) already declare
BaseInstanceState?, so the generated helpers now match them.
The generated FindChild override gets the same treatment: NodeState
declares replacement as nullable, so the override was carrying a latent
nullability mismatch with its base.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: b019f3a2-f58d-4f7e-93db-c8a0dec656e2
Conflict in tests/Opc.Ua.SourceGeneration.Core.Tests/Generators/
NodeStateGeneratorTests.cs: master rewrapped the expected call-site
strings to stay inside the line-length limit while this branch appended
the assignInstanceNodeIds: false argument to them. Kept master's wrapped
form and re-applied the argument.
master also added two new templates - Create_MethodArguments and
Assign_MethodArgumentValues - that call
state.CreateOrReplace{InputArguments,OutputArguments}(context, null)
while building a declaration subtree. Both now pass
assignInstanceNodeIds: false, which preserves master's stated intent of
"leaving the identity of the property node created by its own child
factory untouched": without the opt-out the helper would mint a
per-instance NodeId for the arguments property during type-template
construction.
Verified after the merge: SourceGeneration.Core 3722 passed,
Di 303 passed, Types 8083 passed on net10.0.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: b019f3a2-f58d-4f7e-93db-c8a0dec656e2
The Opc.Ua.SourceGeneration.Tests jobs (net10.0 and net48) compile
generated model code against the hand-written Opc.Ua API stub in
tests/Opc.Ua.SourceGeneration.Core.Tests/CompilerUtils.cs. The stub's 73
CreateOrReplace<Child> declarations still had the old two-parameter
signature, so every generated call site that now passes
assignInstanceNodeIds: false failed to bind (CS1739), producing up to
1430 compile errors per test case. The stub now mirrors the real API:
nullable replacement plus the optional assignInstanceNodeIds parameter.
NodesetMethodArgumentTests pinned the exact emitted call
`state.CreateOrReplace{Input,Output}Arguments(context, null)`. That call
site intentionally changed in the master merge - the generated method
factories opt out of NodeId assignment so the arguments property keeps
the identity its own child factory gives it - so the expectation is
updated to match rather than relaxed.
Verified: Opc.Ua.SourceGeneration.Tests 81 passed on net10.0 and net48
(was 25 failed / 56 passed), Opc.Ua.SourceGeneration.Core.Tests 3722
passed.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: b019f3a2-f58d-4f7e-93db-c8a0dec656e2
Master landed #4104, which assigns instance NodeIds inside the generated CreateOrReplace<Child> helpers - the issue this branch opened. Two files conflicted. docs/DependencyInjection.md: additive on both sides, so both sets of rows are listed. PumpDeviceIntegrationServer: master deleted the sample's manual AssignChildNodeIds walk and its private helper, because the generator now does that work. That deletion is kept, and this branch's AttachOpenUsdRepresentation call is preserved alongside it. Three follow-on fixes the textual merge could not make. The branch's other OpenUSD subtrees still called the now-deleted private helper, so they use master's public replacement, ISystemContext.AssignInstanceChildNodeIds, which has the same recursive NodeIdFactory semantics plus reference fix-up. Two comments that referenced the removed call were corrected. And VirtualFileSystem's per-file memory map was raised from 32 MB to 256 MB: the generator writes whole model sources through it, and #4104's extra per-child code pushed Opc.Ua.Pumps.NodeStates.ex.g.cs to 32.1 MB, so generation failed with MODELGEN003. The map uses DelayAllocatePages, so this reserves address space rather than allocating. NOT READY TO PUSH. Four tests fail deterministically because #4104 changed when instance NodeIds are allocated, and this branch's Robotics NodeId allocator and its expectations were built around the previous flow: NewSkipsExistingNumericNodeId and BuildContextAllocatorSkipsExistingNumericNodeId expect ns=5;i=2 but get ns=5;i=11, FailedBuildsReleaseStockReservations expects 0 released reservations but sees 10, and ConfiguredPumpExposesTheFullModelSurface sees the same renumbering. Reconciling the allocator's reservation semantics with the new assignment point needs the author's intent, so it is deliberately left unresolved rather than guessed at. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8cbb8cd0-f0cb-4ab0-bea2-6202fbf69485
Since #4104 the generated CreateOrReplace<Child> helpers assign a per-instance NodeId whenever the system context carries a NodeIdFactory. NodeState.Initialize materialises the children of a copy through CreateChild, which goes through that same plumbing - but the copy overwrites every child NodeId from its source right afterwards, so each of those assignments is discarded. For a counting factory that silently burns identifiers. For a factory that tracks outstanding allocations it leaks them: booting the Robotics node manager left ten reservations behind, held by nodes that no longer carried the reserved NodeId, taken while the generated model copied the AddComment/Acknowledge and CreateDirectory/CreateFile/MoveOrCopy argument properties of the ConditionType and FileDirectoryType declarations. Those ten identifiers were then skipped by every later allocation. The assignInstanceNodeIds flag cannot carry the intent here, because it is not part of the virtual FindChild contract the copy passes through - the declaration factories already pass false and it is lost at NodeState.Create. Hide the factory from the copy instead, via a context wrapper that forwards everything else; the existing assignment sites already treat a missing factory as "do not assign". Also refreshes the pump NodeId surface baseline, which had not seen the OpenUSD representation, component and signal nodes the sample adds. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8cbb8cd0-f0cb-4ab0-bea2-6202fbf69485
Description
The model source generator emits two families of instance helpers with inconsistent NodeId behaviour:
ISystemContext.CreateInstanceOf<Type>(parent, browseName)rebases the whole materialised subtree onto per-instance NodeIds.<Owner>.CreateOrReplace<Child>(context, replacement)— the plumbing behindNodeState.CreateChild/ReplaceChild— did not. The child it produced was either bare (null NodeId, no browse name) or kept the type-level NodeId of the replacement.Any node manager mixing the two had to hand-roll a recursive re-stamp before registration, or sibling instances collided on NodeIds — a failure that only surfaces at registration time.
samples/PumpDeviceIntegrationServerdid exactly this.What changed
Generator (
tools/Opc.Ua.SourceGeneration.Core)CreateOrReplace<Child>gainsbool assignInstanceNodeIds = true. When enabled and the context supplies anISystemContext.NodeIdFactory, a child whose NodeId is null or still equal to its declaration NodeId — and its descendants — are rebased throughNodeInstanceExtensions.AssignInstanceNodeId/AssignInstanceChildNodeIds, with the owner as reference root. A NodeId the caller already assigned is never overwritten.SymbolicName/BrowseName/DisplayName. Without an identity, a path-basedINodeIdFactoryderives{parent}_for every child of a parent, so the assignment would have introduced collisions rather than removed them.BrowseNamestamping is guarded oncontext.NamespaceUris != null— event-reporting paths pass a context without a namespace table.Create*factory call sites passassignInstanceNodeIds: false: they build declaration subtrees whose NodeIds must stay at their type-level values, and the enclosingCreateInstanceOf<Type>factory rebases the finished subtree in a single pass.CreateInstanceOf<Type>rebase guard drops theparent != nullrequirement — supplying abrowseNameis what marks a dynamically materialised instance. Calling it without one (as the generatedNodeStateActivators do, and when replacing a well-known singleton) still keeps the declaration NodeIds.replacementis now emitted asBaseInstanceState?onCreateOrReplace<Child>and on the generatedFindChildoverride, matching the documented contract and the baseNodeState.FindChildsignature.Hand-written twins —
MethodState.CreateOrReplace{Input,Output}ArgumentsandBaseDataVariableState.CreateOrReplaceEnumStringsgain the same parameter and behaviour, sharing a new internalNodeInstanceExtensions.AssignNewChildInstanceNodeIds. This is also required so the generatedpublic newoverrides keep hiding a matching base signature (otherwiseCS0109withTreatWarningsAsErrors).Sample —
PumpDeviceIntegrationServernow passes the realDeviceSetparent to the factory and dropsAssignChildNodeIdsplus the manual root stamp.Docs — new "Materialising instances at runtime — NodeId assignment" section in
SourceGeneratedNodeManagers.mdand a refreshedDeviceIntegration.md.Design note: why not the guard proposed in the issue
Gating on "the owner already has an instance NodeId" cannot be implemented as
owner.NodeId != <ownerTypeNodeId>. The generated child factories call<Owner>.CreateOrReplace<Child>on declaration instances —CreatePumpType_Operationalruns on aFunctionalGroupStatewhose NodeId isObjectIds.PumpType_Operational, notObjectTypeIds.FunctionalGroupType— so the comparison would be true while the type template is being materialised (forInstance: false) and would rewrite the address space's declaration NodeIds. The explicit opt-out separates the two callers unambiguously.Compatibility
Adding an optional parameter is source-compatible. Behaviour changes for callers of
NodeState.CreateChild/ReplaceChildwhen the context carries aNodeIdFactory: the new child now carries a browse name and a per-instance NodeId instead of nothing.Related Issues
Note
The issue asks to delete
AssignChildNodeIdsfromsamples/MinimalRobotServer. That sample does not exist onmaster— it only lives on the still-open #4096 branch and carries no such helper. The equivalent boilerplate onmasterisPumpDeviceIntegrationServer/PumpNodeManager, which this PR cleans up. Details in this comment.Note
One commit is an unrelated one-line-scope build-hygiene fix: two
CA2000warnings inConsoleReferenceClientwhere subscription ownership transfers to the session. Happy to split it out if preferred.Checklist
Tests added
Generator emission (
NodeManagerGeneratorTests) —CreateOrReplaceChildAssignsInstanceNodeIdsByDefault,TypeFactoriesOptOutOfCreateOrReplaceNodeIdAssignment,CreateInstanceOfFactoriesRebaseWithoutAnExplicitParent.Helper behaviour (
NodeInstanceExtensionsTests) —CreateOrReplaceArgumentsAssignsInstanceNodeIds,CreateOrReplaceArgumentsKeepsCallerAssignedNodeId,CreateOrReplaceArgumentsHonoursTheAssignmentOptOut,CreateOrReplaceEnumStringsAssignsInstanceNodeIds.Pump DI server regression (
PumpInstanceNodeIdRegressionTests, added on review feedback):ConfiguredPumpExposesTheFullModelSurfaceEveryPumpInstanceExposesTheSameGeneratedModelSurfaceConfigurepass adds.MaterialisedPumpNodeIdsComeFromTheNodeIdFactory{parentIdentifier}_{symbolicName}in the parent's namespace — minted byPumpNodeManager.New.MaterialisedPumpNodeIdsAreUniqueAcrossInstancesGeneratedFactoryAssignsInstanceNodeIdsBeforeRegistrationAddPredefinedNodeAsyncapplies its defensive rebase.AlarmSubtreeKeepsStandardDeclarationNodeIdsThe 77-identifier baseline was captured by running the same subtree walk against the pre-change commit and diffing it against the post-change run: identical — same node set, same NodeIds.
Local test runs
Opc.Ua.SourceGeneration.Core.TestsOpc.Ua.SourceGeneration.TestsOpc.Ua.SourceGeneration.Stack.TestsOpc.Ua.Types.TestsOpc.Ua.Di.TestsOpc.Ua.Core.TestsOpc.Ua.Gds.TestsOpc.Ua.History.TestsOpc.Ua.Server.TestsThe single
Opc.Ua.Server.Testsfailure —ServerFluentApiHostingTests.ConfigureApplicationBuildsSharedClientAndServerConfigurationAsync— is pre-existing and unrelated; it reproduces on a stashed cleanmastertree.Note
The source-generation test harness compiles generated model code against a hand-written
Opc.UaAPI stub (tests/Opc.Ua.SourceGeneration.Core.Tests/CompilerUtils.cs, shared by three test projects). Its 73CreateOrReplace<Child>declarations were updated to the new signature — without that, every generated call site passingassignInstanceNodeIds: falsefailed to bind.NodesetMethodArgumentTestspinned the exact emitted call and was updated to the new one for the same reason as theNodeStateGeneratorTestsexpectations.Note
masterhas been merged into this branch. The only conflict was inNodeStateGeneratorTests.cs, where master rewrapped the expected call-site strings while this branch appendedassignInstanceNodeIds: falseto them — kept master's wrapping and re-applied the argument. Master's two new templates (Create_MethodArguments,Assign_MethodArgumentValues) callCreateOrReplace{Input,Output}Argumentswhile building a declaration subtree, so they now passassignInstanceNodeIds: falseto preserve their stated intent of leaving the property node's identity untouched.Tip
Changing a source generator leaves dependent projects' cached generated output stale. If a local build reports
CS1739: ... does not have a parameter named 'assignInstanceNodeIds'orCS0109on a generatedCreateOrReplace*, rundotnet build <project> -t:Rebuild. Clean CI builds are unaffected.