Source-generated, NativeAOT-clean fluent server API with event-source publish#3765
Merged
Merged
Conversation
…nager Introduces an end-to-end async path for variable Value-attribute reads and writes that does not require holding lock(this) while the user callback executes: * New AttributeReadResult, AttributeSimpleReadResult and AttributeWriteResult readonly record structs in NodeState carry the typed result of an async Value-attribute hook (no ref/out params across await). * Four new async delegates (NodeValueEventHandlerAsync, NodeValueSimpleEventHandlerAsync, NodeValueWriteEventHandlerAsync, NodeValueSimpleWriteEventHandlerAsync) sit alongside the existing sync hooks. * NodeState gains virtual ReadAttributeAsync / WriteAttributeAsync methods. The default implementation wraps the synchronous call in lock(this) so callers that have not opted into async hooks see bit-identical behaviour. * BaseVariableState exposes OnReadValueAsync, OnSimpleReadValueAsync, OnWriteValueAsync and OnSimpleWriteValueAsync slots and overrides the new virtuals to dispatch to them WITHOUT holding the lock during the await. On simple-read the framework still applies index range, data encoding and copy policy; on simple-write the cached value/status/timestamp are updated under lock after the hook completes. * AsyncCustomNodeManager.ReadAsync and WriteAsync now await the new ReadAttributeAsync / WriteAttributeAsync entry points instead of taking lock(source) around the synchronous call. Existing per-source locking semantics are preserved by the default async wrappers. * The fluent INodeBuilder surface gains four new async OnRead/OnWrite overloads that wire to the new BaseVariableState slots, with the same ThrowIfSlotOccupied + null-check pattern as the sync overloads. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Adds typed sub-interfaces and impls so the existing fluent surface can expose simple Func<T> / Action<T> / Func<CancellationToken, ValueTask<T>> overloads for variables — without losing the lower-level Variant-based overloads on INodeBuilder. * IVariableBuilder<TValue> + VariableBuilder<TValue> derive from INodeBuilder<BaseVariableState> / NodeBuilder<BaseVariableState> (NodeBuilder<TState> unsealed). * Marshalling uses Variant.AsBoxedObject(BoxingBehavior.Legacy) for reads and Variant(object) for writes; the AOT-unsafe write path is scoped-suppressed with a TODO pointing at the planned per-type generated walker. * INodeManagerBuilder gains Variable<T>(string), Variable<T>(NodeId), VariableFromTypeId<T>(NodeId), VariableFromTypeId<T>(NodeId, QualifiedName). Server library builds clean across all 6 TFMs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Adds INodeBuilder.Child(QualifiedName), Child<TState>(QualifiedName) and Variable<TValue>(QualifiedName) so source-generated typed wrappers can walk one segment at a time without re-resolving from the manager root. Resolution uses NodeState.FindChild and reuses NodeManagerBuilder.ToVariableBuilder to keep the typed-variable marshalling story consistent. Adds 7 NUnit cases (Fluent category) covering the happy path, type-mismatch, null/missing browse-name, and non-variable rejection. All 66 fluent tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
BoilerNodeManager.Configure now exercises five wiring styles:
(1) browse-path with the legacy ref-Variant callback
(2) absolute NodeId with the legacy ref-Variant callback
(3) typed Variable<double>(NodeId) + simple Func<double> sync getter
(4) typed Variable<double>(NodeId) + Func<CancellationToken, ValueTask<double>>
async getter — exercises the BaseVariableState.OnReadValueAsync
slot end-to-end through AsyncCustomNodeManager.ReadAsync
(5) NodeFromTypeId with OnNodeAdded lifecycle hook
All five styles compose against the same INodeManagerBuilder, with
the typed and async callbacks pulling in the new IVariableBuilder<T>
surface (committed at e557ecd) and the BaseVariableState async
path (committed at 2e2efb4).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
15 NUnit cases covering the new On{Read,Write}ValueAsync and On
Simple{Read,Write}ValueAsync slots plus the
ReadAttributeAsync/WriteAttributeAsync overrides on
BaseVariableState:
* full async slot routes Value reads/writes
* lock(this) is released around the awaited handler
* simple async slot reapplies cached StatusCode and respects index
ranges (BadIndexRangeInvalid for non-null ranges)
* exceptions and OperationCanceledException from the hook are
caught and surfaced as BadUnexpectedError, mirroring the sync flow
* CancellationToken propagates to the hook
* cache (m_value, m_statusCode, m_timestamp) is updated on success
and skipped on Bad return
* fallback to base sync ReadAttribute/WriteAttribute when no async
slot is set (preserves today's lock(this) semantics)
* non-Value attributes never invoke the async hook
* CurrentRead/CurrentWrite access checks short-circuit before the
hook runs
All 596 State tests pass on net10.0.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Covers the typed Func<T>/Action<T>/Func<...,ValueTask<T>> overloads on IVariableBuilder<TValue>: each registers the appropriate hook slot on the underlying BaseDataVariableState, and driving the variable through ReadAttribute/WriteAttribute (sync) and ReadAttributeAsync/WriteAttributeAsync (async) reproduces the typed values the user supplied. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Adds FluentBuilderGenerator that walks the design's predefined-instance tree and emits a typed sibling for every NodeManagerBuilder. Each generated wrapper exposes IntelliSense-friendly accessors for child instances, variables (IVariableBuilder<T>) and methods (sync/async OnCall) so wiring sites become builder.Boilers.Boiler__1.LCX001.Measurement.OnRead(...) instead of a stringly typed browse path. The NodeManager template now also emits a Configure(I{Manager}NodeManagerBuilder) partial alongside the existing Configure(INodeManagerBuilder); both partials run, both are optional, and the typed surface is fully AOT-safe (no dynamic, no MakeGenericType, no reflection).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Adds 9 snapshot tests in Tests/Opc.Ua.SourceGeneration.Core.Tests covering the FluentBuilderGenerator's opt-in flag, typed-manager-interface and proxy emission, internal-sealed visibility, lazy child resolution via Context.NamespaceUris.GetIndexOrAppend, IVariableBuilder<TValue> accessors, sync+async OnCall overloads on method wrappers, and the dual Configure(INodeManagerBuilder)/Configure(I{Manager}NodeManagerBuilder) partial wiring in the NodeManager template. Adds 2 end-to-end AOT tests in Tests/Opc.Ua.Aot.Tests that exercise typed-traversal sync read on LCX001/Measurement and typed-traversal async OnCall on Simulation/Halt through the AOT-compiled MinimalBoilerServer fixture.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…lder) partial
Adds a section to Docs/SourceGeneratedNodeManagers.md walking through the source-generated typed builder: the second Configure partial whose builder parameter exposes IntelliSense accessors for every predefined instance, child, variable and method in the model. Includes a side-by-side untyped+typed example mirroring MinimalBoilerServer's BoilerNodeManager.Configure.cs and itemizes the per-model emit (interface, proxy, instance wrappers, method wrappers) so consumers know exactly what surface ships in {Manager}.FluentBuilders.g.cs.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The AOT integration test `ConnectAndCloseSessionAsync` failed because `SessionManager.CloseSessionAsync` (server-side) had its `ConcurrentDictionary.TryRemove` condition inverted in commit 70b4498: when the session was successfully removed the method returned without actually closing it, and a stray `Debug.Assert(session == null)` was placed on the success branch where `session` is in fact the just-removed value. The assertion was previously invisible because `Debug.Assert` only logs on most runners, but TUnit installs a `ThrowListener` that converts `Debug.Fail` calls into thrown `TUnitException` instances, which the server then surfaces as `BadUnexpectedError` in the `CloseSession` response. `Session.CloseAsync` propagates that status to the caller, so the client test's `Assert.That(result).IsEqualTo(StatusCodes.Good)` failed. Restore the original logic: when `TryRemove` fails the entry was already removed by a concurrent caller (so `session` stays null) and the method bails; when it succeeds the loop breaks and the code below disposes the session and updates the diagnostics counters. After the fix the previously failing test passes and all 73 AOT tests succeed; `Opc.Ua.Server.Tests` Fluent tests (82) also pass on net10.0. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Refactors the FluentBuilderGenerator to emit each per-instance and per-method wrapper as a nested type inside its lexical parent's wrapper, instead of as a flat namespace-scope class with a long underscore-joined name (e.g. Boilers_Boiler__1_PipeX001_FTX001Builder). The user-facing fluent surface is unchanged because property names on accessors do not change — only the underlying class names. Changes: - Add LeafName / ParentKey / ChildObjectKeys / ChildMethodKeys to InstanceWrapper and LeafName / ParentKey to MethodWrapper. - Replace ComposeClassName with ResolveLeafName, ResolveParentKey and ComposeWrapperClassName helpers. - Add LinkChildWrappers post-pass to wire each parent to its direct child wrappers (sorted by ordinal leaf name). - Refactor Emit to walk only top-level wrappers and recurse via the new ChildObjectKeys / ChildMethodKeys. - Thread an explicit indent parameter through EmitInstanceWrapper, EmitChildAccessor, EmitMethodWrapper, EmitMethodOnCall, EmitInputUnpack and EmitOutputBox so each nesting level adds another 4 spaces to every emitted line. Avoid the writer's Push/Pop indentation API which discards pending newlines. - Add three new snapshot tests: TopLevelInstanceWrapperLivesAt NamespaceScope, MethodWrapperIsNestedInsideOwningObject, and ChildAccessorReturnsSimpleLeafName. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Adds a new MathInstance predefined Object instance to TestModel.xml
that hosts two methods declared with inline InputArguments and
OutputArguments — Compute(x: Int32 -> result: Int32) and
Add(a: Int32, b: Int32 -> sum: Int32). This finally exercises the
FluentBuilderGenerator code path that emits typed OnCall(...)
overloads (EmitInputUnpack / EmitOutputBox / FormatInputTypeList),
which previously had zero snapshot coverage because the test model
contained no predefined method instance with arguments.
Adds five FluentBuilderGenerator snapshot tests asserting:
* MethodWithIntInputAndOutputEmitsTypedSyncOverload — sync
OnCall(Func<int,int>) is emitted on ComputeMethodBuilder.
* MethodWithIntInputAndOutputEmitsTypedAsyncOverload — async
OnCall(Func<int,CancellationToken,ValueTask<int>>) is emitted.
* MethodInputUnpackUsesVariantTryGetValue — generated lambda body
unpacks Variants via TryGetValue and short-circuits with
BadInvalidArgument / BadArgumentsMissing as appropriate.
* MethodOutputBoxUsesVariantFrom — single-output methods box the
handler return value via Variant.From(__r).
* MethodWithMultipleInputArgsEmitsCorrectArity — multi-input
methods (Add) produce Func<int,int,int> sync and
Func<int,int,CT,ValueTask<int>> async overloads, and unpack
each Variant by index.
FluentBuilderGenerator tests grow from 12 to 17; full source-gen
suite passes 3511/0/8 across net8/9/10/472/48 (was 3506/0/8).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Adds an end-to-end sample exercising the typed source-generated `OnCall` overloads on methods with input/output arguments — sync `int+int -> int`, async `double+double -> double`, and sync `string+string -> string` — plus AOT round-trip tests covering each shape via `Session.CallAsync`. Fixes a runtime bug in `FluentBuilderGenerator.EmitOutputBox` discovered by the new tests: the typed wrapper appended boxed return values to the outputs list with `__outputs.Add(...)` while the base `MethodState.Call` dispatcher pre-populates the list with default `Variant` slots — one per declared output argument — before invoking the user handler. The result was double-counted outputs at the wire. The wrapper now assigns by index (`__outputs[i] = ...`); the matching unit assertion in `FluentBuilderGeneratorTests` is updated. The sample model uses the proven `ModelDesign` pattern (top-level `MethodType` declarations + `CalculatorType` `ObjectType` + `Calculator` predefined instance with an explicit inverse `Organizes` reference back to `ObjectsFolder`) so the typed `MethodState` subclasses, fluent builders and a published instance all reach the address space. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Adds a "Methods with arguments — typed `OnCall` overloads" section to `Docs/SourceGeneratedNodeManagers.md` describing the typed sync and async `OnCall(Func<...>)` overloads, `Variant.TryGetValue<T>` input-unpack semantics, `Variant.From<T>` output-box semantics, and the multi-output `ValueTuple` shape. Updates the "What the generator emits per model" bullet to point at the new section, and lists `Applications/MinimalCalcServer/` alongside the existing Boiler sample with a pointer at the companion AOT round-trip tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Adds the runtime that lets fluent users register IAsyncEnumerable<TEvent> sources on event-notifier nodes via builder.Node<...>().Publish(...). FluentNodeManagerBase opt-in base owns an EventSourceRegistry that activates and deactivates streams in lock-step with NodeState.AreEventsMonitored, dispatches events through node.ReportEvent, and tears down cleanly on Dispose. EventPublishOptions exposes lazy / eager activation, RegisterAsRootNotifier, OnError, and a CancellationTimeout for shutdown. Wires the registry into NodeManagerBuilder via FluentNodeManagerBase.AttachToBuilder(...) which the source-generator-emitted CreateAddressSpaceAsync calls right before the user's Configure partial runs (a no-op for non-fluent managers, so behavior is unchanged for existing CustomNodeManager2-based generators). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Adds 21 unit tests covering the runtime introduced in cf3f1f3: lazy/eager activation, monitor/unmonitor cycles, event delivery and default-population, SkipDefaultPopulation, factory and iterator error paths with OnError, duplicate registration, timeout validation, null-arg validation, EventNotifier auto-promotion, root-notifier registration, dispose cancellation propagation, and the Publish extension on attached vs. non-attached builders. Tests use a TestablePublishManager that exposes the protected RootNotifiers/PredefinedNodes accessors and a helper to drop a notifier into the predefined-nodes table without requiring a full master-node-manager wiring. Race-free assertions use TaskCompletionSource hooks instead of polling. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Generated NodeManager partials now inherit from FluentNodeManagerBase instead of AsyncCustomNodeManager. Behavior is unchanged for managers without Publish bindings — FluentNodeManagerBase only adds an idle EventSourceRegistry that is disposed with the manager. With the base type fixed, the conditional cast in CreateAddressSpaceAsync collapses to a direct AttachToBuilder(__m_builder) call which makes Publish(...) extensions usable without any user opt-in. Updates the snapshot assertion in NodeManagerGeneratorTests accordingly. Also stabilizes PublishTests under parallel CPU load: bumps s_signalTimeout from 5s to 15s and replaces the two hardcoded 50ms warm-up sleeps with TaskCompletionSource hooks signaled when the worker has actually entered the iterator. Verified: source-generator tests 3511/3511, AOT end-to-end 77/77, fluent server tests 103/103 (×5 TFMs). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Closes Phase 3b (pub-gen-typed + pub-snapshot-tests) of the event-source publish runtime work. The fluent builder generator now emits typed Publish<TEvent> overloads on every wrapper whose underlying NodeDesign qualifies as an event notifier in the model. Detection (locked decision: spec-accurate, not blanket): - ObjectDesign.SupportsEvents is set (the design-XML form of EventNotifier=SubscribeToEvents; the model validator already auto-promotes nodes carrying forward HasEventSource/HasNotifier references), OR - the node has any forward GeneratesEvent or AlwaysGeneratesEvent reference in its References collection. Each qualifying wrapper now exposes two overloads matching the runtime extension's shape: - Publish<TEvent>(IAsyncEnumerable<TEvent>, EventPublishOptions?) - Publish<TEvent>(Func<TNotifier, ISystemContext, CancellationToken, IAsyncEnumerable<TEvent>>, EventPublishOptions?) Both forward to EventNotifierBuilderExtensions.Publish with the wrapper's underlying state type bound as TNotifier so callers don't need to spell it out. EventPublishOptions is annotated as nullable because the generated file uses #nullable enable. Snapshot coverage in TestModel.xml + FluentBuilderGeneratorTests: - NotifierObject (SupportsEvents=true) -> Publish<TEvent> emitted. - EventEmittingObject (forward GeneratesEvent reference) -> emitted. - TestObject (plain object) -> Publish<TEvent> NOT emitted. Verification: - Source-generator tests: 3511/3511 (8 pre-existing skips) on net10.0. - Fluent + AsyncCustom server tests: 345/345 across 5 TFMs (net472/net48/net8.0/net9.0/net10.0). - AOT integration: 77/77 on net10.0 with Boiler/Calculator generated managers — the Boiler model legitimately produces wrappers with Publish<TEvent> on its state-machine notifiers via SupportsEvents inherited from FiniteStateMachineType. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Phase 4-5 of the event-source publish runtime: * MinimalBoilerServer: wire `builder.Boilers.Boiler__1.DrumX001 .Publish<BaseEventState>(GenerateDrumHeartbeatAsync)` in the typed Configure partial. The async iterator emits a synthetic 500 ms heartbeat event with Severity/Message; the registry auto-populates EventId/EventType/SourceNode/SourceName/Time/ReceiveTime. Lazy activation: the iterator only runs while a client is monitoring. * Tests/Opc.Ua.Aot.Tests/PublishedEventsAotTests.cs: end-to-end AOT round-trip. A real client subscription with EventFilter on EventNotifier of the DrumX001 node receives the heartbeats; asserts SourceName/Severity/Message on the EventFieldList. 78/78 AOT tests pass on net10.0 (was 77). * Docs/SourceGeneratedNodeManagers.md: new `Event sources` section covering the typed `Publish<TEvent>` overload, the two registration shapes (direct stream + factory), `EventPublishOptions` for lifecycle tuning, where the typed overload appears (SupportsEvents OR GeneratesEvent), and the hand-written-manager opt-in via `FluentNodeManagerBase`. Verification: * MinimalBoilerServer: build clean. * Sgen tests: build clean. * Fluent + AsyncCustomNodeManager server tests: 345/345 on net10.0. * AOT tests: 78/78 on net10.0. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
|
# Conflicts: # Libraries/Opc.Ua.Server/Session/SessionManager.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Proposed changes
This PR introduces a source-generated, NativeAOT-clean fluent server API that lets developers build OPC UA servers as single-file applications. It composes three layers, each landing as its own series of commits:
1. Async-aware fluent NodeManager runtime (
Opc.Ua.Server.Fluent)INodeManagerBuilder/INodeBuilderAPI for wiring callbacks against the predefined-node tree by browse path, NodeId, or TypeId — no reflection, noActivator.CreateInstance, noExpression.Compile.BaseVariableStateandAsyncCustomNodeManagerso handlers canawaitoutside the manager lock.IVariableBuilder<T>surface that erases ref-Variant boilerplate.INodeBuilderfor ad-hoc descents.2. Source-generated typed wrappers +
[NodeManager]opt-inFluentBuilderGeneratoremits one IntelliSense-aware accessor per predefined instance, child, variable, and method in the model. Wiring a non-existent node becomes a compile-time error rather than a startupServiceResultException.OnRead/OnWrite/OnCalloverloads with arguments — including the multi-argument shapes (int+int → int,double+double → double,string+string → string).Configure(I{Manager}NodeManagerBuilder)partial sits next to the untypedConfigure(INodeManagerBuilder)partial; both run, and wiring the same node from both is detected at startup.3. Event-source publish runtime — typed
Publish<TEvent>on notifier wrappersEventSourceRegistryowns the lifecycle ofIAsyncEnumerable<TEvent>event sources: lazy activation gated onAreEventsMonitored, cooperative cancellation on the last unsubscribe, bounded shutdown on manager disposal.FluentNodeManagerBaseis now the default base class emitted for source-generated managers; hand-written managers can opt in by deriving from it.Publish<TEvent>overload only on wrappers whose underlying node qualifies as an event source (SupportsEventsOR forwardGeneratesEvent/AlwaysGeneratesEventreference).TEventis constrained toBaseEventState.EventPublishOptionsexposesAlwaysOn,SkipDefaultPopulation,RegisterAsRootNotifier,CancellationTimeout,OnError. The registry auto-populatesEventId/EventType/SourceNode/SourceName/Time/ReceiveTime/Severity/Messageso iterators only set the user-meaningful fields.Serverreach events emitted on any registered notifier.Samples + AOT round-trip tests
Applications/MinimalBoilerServer— single-file Boiler server (~12-lineProgram.cs) that wires reads, async reads, async methods, and the new typedPublish<BaseEventState>on the drum notifier.Applications/MinimalCalcServer— calculator server demonstrating typed methods-with-arguments end-to-end.Tests/Opc.Ua.Aot.Tests/PublishedEventsAotTests.cs+BoilerNodeManagerAotTests.cs+CalculatorNodeManagerAotTests.cs— all run under NativeAOT (no JIT, no reflection) and exercise reads, calls, and event monitoring through real clientSubscription/MonitoredItem/EventFilter.Documentation
Docs/SourceGeneratedNodeManagers.md(~500 lines) covers per-class[NodeManager]opt-in, bothConfigurepartials, the typed model-traversal surface, methods-with-arguments overloads, the new event-sourcePublish<TEvent>API (lifecycle + options + two registration shapes), the single-fileProgram.csshape, multi-namespace + manager-swap subclassing, and NativeAOT publishing.Related Issues
Types of changes
Checklist
Local verification snapshot
Opc.Ua.SourceGeneration.Core.Tests: 3511 / 3511 on net10.0 (8 pre-existing skips).Opc.Ua.Server.Tests(Fluent + AsyncCustomNodeManager): 345 / 345 on net472 / net48 / net8.0 / net9.0 / net10.0.Opc.Ua.Aot.Tests(Boiler + Calculator + PublishedEvents): 78 / 78 on net10.0.MinimalBoilerServer/MinimalCalcServer: build clean.Further comments
The work is staged into 19 small, self-contained commits — easiest to review one commit at a time. The locked design decisions, especially around the event-publish surface (lazy activation, single typed overload per qualifying notifier, dispose timing derived from
max(per-source CancellationTimeout) + 5 s), are documented inline inEventSourceRegistry.csand in the newDocs/SourceGeneratedNodeManagers.md"Event sources" section.