Add async read/write hooks to BaseVariableState and AsyncCustomNodeManager

Introduces an end-to-end async path for variable Value-attribute reads
and writes that does not require holding lock(this) while the user
callback executes:

* New AttributeReadResult, AttributeSimpleReadResult and AttributeWriteResult
  readonly record structs in NodeState carry the typed result of an async
  Value-attribute hook (no ref/out params across await).
* Four new async delegates (NodeValueEventHandlerAsync,
  NodeValueSimpleEventHandlerAsync, NodeValueWriteEventHandlerAsync,
  NodeValueSimpleWriteEventHandlerAsync) sit alongside the existing sync
  hooks.
* NodeState gains virtual ReadAttributeAsync / WriteAttributeAsync methods.
  The default implementation wraps the synchronous call in lock(this) so
  callers that have not opted into async hooks see bit-identical behaviour.
* BaseVariableState exposes OnReadValueAsync, OnSimpleReadValueAsync,
  OnWriteValueAsync and OnSimpleWriteValueAsync slots and overrides the new
  virtuals to dispatch to them WITHOUT holding the lock during the await.
  On simple-read the framework still applies index range, data encoding and
  copy policy; on simple-write the cached value/status/timestamp are
  updated under lock after the hook completes.
* AsyncCustomNodeManager.ReadAsync and WriteAsync now await the new
  ReadAttributeAsync / WriteAttributeAsync entry points instead of taking
  lock(source) around the synchronous call. Existing per-source locking
  semantics are preserved by the default async wrappers.
* The fluent INodeBuilder surface gains four new async OnRead/OnWrite
  overloads that wire to the new BaseVariableState slots, with the same
  ThrowIfSlotOccupied + null-check pattern as the sync overloads.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Marc Schier
2026-05-12 12:32:22 +02:00
parent 70b4498db8
commit 2e2efb4ad9
5 changed files with 602 additions and 18 deletions
@@ -93,6 +93,31 @@ namespace Opc.Ua.Server.Fluent
/// </summary>
INodeBuilder OnWrite(NodeValueSimpleEventHandler handler);
/// <summary>
/// Wires <see cref="BaseVariableState.OnReadValueAsync"/>. Use this
/// overload when the read source performs I/O — the handler runs
/// without holding <c>lock(this)</c>, so the call can <c>await</c>
/// freely.
/// </summary>
INodeBuilder OnRead(NodeValueEventHandlerAsync handler);
/// <summary>
/// Wires <see cref="BaseVariableState.OnSimpleReadValueAsync"/>.
/// The framework still applies index-range / data-encoding /
/// copy-policy post-processing to the returned value.
/// </summary>
INodeBuilder OnRead(NodeValueSimpleEventHandlerAsync handler);
/// <summary>
/// Wires <see cref="BaseVariableState.OnWriteValueAsync"/>.
/// </summary>
INodeBuilder OnWrite(NodeValueWriteEventHandlerAsync handler);
/// <summary>
/// Wires <see cref="BaseVariableState.OnSimpleWriteValueAsync"/>.
/// </summary>
INodeBuilder OnWrite(NodeValueSimpleWriteEventHandlerAsync handler);
/// <summary>
/// Wires <see cref="MethodState.OnCallMethod2"/>.
/// </summary>
@@ -104,6 +104,42 @@ namespace Opc.Ua.Server.Fluent
return this;
}
/// <inheritdoc/>
public INodeBuilder OnRead(NodeValueEventHandlerAsync handler)
{
BaseVariableState v = RequireVariable("OnReadAsync");
ThrowIfSlotOccupied(v.OnReadValueAsync, "OnReadAsync");
v.OnReadValueAsync = handler ?? throw new ArgumentNullException(nameof(handler));
return this;
}
/// <inheritdoc/>
public INodeBuilder OnRead(NodeValueSimpleEventHandlerAsync handler)
{
BaseVariableState v = RequireVariable("OnSimpleReadAsync");
ThrowIfSlotOccupied(v.OnSimpleReadValueAsync, "OnSimpleReadAsync");
v.OnSimpleReadValueAsync = handler ?? throw new ArgumentNullException(nameof(handler));
return this;
}
/// <inheritdoc/>
public INodeBuilder OnWrite(NodeValueWriteEventHandlerAsync handler)
{
BaseVariableState v = RequireVariable("OnWriteAsync");
ThrowIfSlotOccupied(v.OnWriteValueAsync, "OnWriteAsync");
v.OnWriteValueAsync = handler ?? throw new ArgumentNullException(nameof(handler));
return this;
}
/// <inheritdoc/>
public INodeBuilder OnWrite(NodeValueSimpleWriteEventHandlerAsync handler)
{
BaseVariableState v = RequireVariable("OnSimpleWriteAsync");
ThrowIfSlotOccupied(v.OnSimpleWriteValueAsync, "OnSimpleWriteAsync");
v.OnSimpleWriteValueAsync = handler ?? throw new ArgumentNullException(nameof(handler));
return this;
}
/// <inheritdoc/>
public INodeBuilder OnCall(GenericMethodCalledEventHandler2 handler)
{
@@ -1962,15 +1962,18 @@ namespace Opc.Ua.Server
DataValue value = values[handle.Index];
// update the attribute value.
lock (source)
{
errors[handle.Index] = source.ReadAttribute(
context,
nodeToRead.AttributeId,
nodeToRead.ParsedIndexRange,
nodeToRead.DataEncoding,
value);
}
// Async path — the default NodeState.ReadAttributeAsync re-takes
// `lock(this)` around the synchronous fallback when no async
// hook is set, so behaviour is preserved bit-for-bit. When a
// BaseVariableState async hook is set the lock is dropped on
// the await, allowing true async I/O.
errors[handle.Index] = await source.ReadAttributeAsync(
context,
nodeToRead.AttributeId,
nodeToRead.ParsedIndexRange,
nodeToRead.DataEncoding,
value,
cancellationToken).ConfigureAwait(false);
}
}
@@ -2372,15 +2375,13 @@ namespace Opc.Ua.Server
WriteValue nodeToWrite = nodesToWrite[handle.Index];
lock (source)
{
// write the attribute value.
errors[handle.Index] = source.WriteAttribute(
context,
nodeToWrite.AttributeId,
nodeToWrite.ParsedIndexRange,
nodeToWrite.Value);
}
// Async path — see ReadAsync for the locking rationale.
errors[handle.Index] = await source.WriteAttributeAsync(
context,
nodeToWrite.AttributeId,
nodeToWrite.ParsedIndexRange,
nodeToWrite.Value,
cancellationToken).ConfigureAwait(false);
// updates to source finished - report changes to monitored items.
source.ClearChangeMasks(context, false);
@@ -33,6 +33,8 @@ using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Runtime.Serialization;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Opc.Ua.Types;
@@ -561,6 +563,45 @@ namespace Opc.Ua
/// </summary>
public NodeValueEventHandler OnWriteValue;
/// <summary>
/// Asynchronous sibling of <see cref="OnReadValue"/>. When set, the
/// async path through
/// <see cref="ReadAttributeAsync(ISystemContext, uint, NumericRange, QualifiedName, DataValue, CancellationToken)"/>
/// invokes this delegate without holding the <c>lock(this)</c>
/// taken by the synchronous fallback — the handler owns its own
/// thread-safety. Has the same "I am the read source"
/// semantics as <see cref="OnReadValue"/>: the framework does
/// <em>not</em> apply post-processing such as
/// <see cref="ApplyIndexRangeAndDataEncoding"/> on the returned
/// value.
/// </summary>
public NodeValueEventHandlerAsync OnReadValueAsync;
/// <summary>
/// Asynchronous sibling of <see cref="OnSimpleReadValue"/>. The
/// returned value is post-processed by the framework
/// (<see cref="ApplyIndexRangeAndDataEncoding"/> and copy policy)
/// just like the synchronous path.
/// </summary>
public NodeValueSimpleEventHandlerAsync OnSimpleReadValueAsync;
/// <summary>
/// Asynchronous sibling of <see cref="OnWriteValue"/>. When set,
/// the async path through
/// <see cref="WriteAttributeAsync(ISystemContext, uint, NumericRange, DataValue, CancellationToken)"/>
/// invokes this delegate without holding <c>lock(this)</c>; on
/// success the framework updates the cached value, status code
/// and timestamp.
/// </summary>
public NodeValueWriteEventHandlerAsync OnWriteValueAsync;
/// <summary>
/// Asynchronous sibling of <see cref="OnSimpleWriteValue"/>. Index
/// range writes are not supported through this hook (just like
/// the synchronous one).
/// </summary>
public NodeValueSimpleWriteEventHandlerAsync OnSimpleWriteValueAsync;
/// <summary>
/// Raised when the DataType attribute is read.
/// </summary>
@@ -1737,6 +1778,315 @@ namespace Opc.Ua
return ServiceResult.Good;
}
/// <summary>
/// Asynchronous override of
/// <see cref="NodeState.ReadAttributeAsync(ISystemContext, uint, NumericRange, QualifiedName, DataValue, CancellationToken)"/>.
/// Dispatches to <see cref="OnReadValueAsync"/> or
/// <see cref="OnSimpleReadValueAsync"/> when set; otherwise falls
/// through to the synchronous read flow under <c>lock(this)</c>
/// (preserving today's locking semantics for code that has not
/// opted into async hooks).
/// </summary>
public override async ValueTask<ServiceResult> ReadAttributeAsync(
ISystemContext context,
uint attributeId,
NumericRange indexRange,
QualifiedName dataEncoding,
DataValue value,
CancellationToken cancellationToken = default)
{
NodeValueEventHandlerAsync onReadValueAsync = OnReadValueAsync;
NodeValueSimpleEventHandlerAsync onSimpleReadValueAsync = OnSimpleReadValueAsync;
if (attributeId != Attributes.Value ||
(onReadValueAsync == null && onSimpleReadValueAsync == null))
{
return await base.ReadAttributeAsync(
context, attributeId, indexRange, dataEncoding, value, cancellationToken)
.ConfigureAwait(false);
}
if (value == null)
{
return ServiceResult.Create(
StatusCodes.BadStructureMissing,
"DataValue missing");
}
ServiceResult result;
Variant valueToRead;
DateTimeUtc sourceTimestamp;
try
{
// snapshot access levels / timestamp / status code under the lock.
uint accessLevel;
byte userAccessLevel;
StatusCode cachedStatusCode;
DateTimeUtc cachedTimestamp;
// TODO: introduce a dedicated private lock object on NodeState
// — today's sync flow synchronises through `lock(source)` taken
// by external callers (e.g. CustomNodeManager2.Read), so the
// async path must lock on the same instance to preserve
// mutual exclusion. Switching to a private lock object
// requires updating every external `lock(source)` site.
#pragma warning disable CA2002 // Do not lock on objects with weak identity
lock (this)
#pragma warning restore CA2002
{
accessLevel = m_accessLevel;
userAccessLevel = m_userAccessLevel;
cachedStatusCode = m_statusCode;
cachedTimestamp = m_timestamp;
}
if ((accessLevel & AccessLevels.CurrentRead) == 0)
{
result = StatusCodes.BadNotReadable;
valueToRead = Variant.Null;
sourceTimestamp = DateTimeUtc.MinValue;
}
else
{
OnReadUserAccessLevel?.Invoke(context, this, ref userAccessLevel);
if ((userAccessLevel & AccessLevels.CurrentRead) == 0)
{
result = StatusCodes.BadUserAccessDenied;
valueToRead = Variant.Null;
sourceTimestamp = DateTimeUtc.MinValue;
}
else if (onReadValueAsync != null)
{
// full async read — user owns timestamp / status code.
AttributeReadResult readResult = await onReadValueAsync(
context, this, indexRange, dataEncoding, cancellationToken)
.ConfigureAwait(false);
result = readResult.Result;
valueToRead = readResult.Value;
sourceTimestamp = readResult.SourceTimestamp == DateTimeUtc.MinValue
? DateTimeUtc.Now
: readResult.SourceTimestamp;
// mirror sync OnReadValue status-code fixup.
if (ServiceResult.IsGood(result) &&
readResult.StatusCode != StatusCodes.Good)
{
result = readResult.StatusCode;
}
}
else
{
// simple async read — framework owns post-processing.
AttributeSimpleReadResult simpleResult = await onSimpleReadValueAsync(
context, this, cancellationToken)
.ConfigureAwait(false);
result = simpleResult.Result;
valueToRead = simpleResult.Value;
sourceTimestamp = cachedTimestamp == DateTimeUtc.MinValue
? DateTimeUtc.Now
: cachedTimestamp;
if (ServiceResult.IsGood(result))
{
ServiceResult rangeResult = ApplyIndexRangeAndDataEncoding(
context, indexRange, dataEncoding, ref valueToRead);
if (ServiceResult.IsBad(rangeResult))
{
result = rangeResult;
}
else
{
if (CopyPolicy is VariableCopyPolicy.CopyOnRead or VariableCopyPolicy.Always)
{
valueToRead = CoreUtils.Clone(valueToRead);
}
if (cachedStatusCode != StatusCodes.Good)
{
result = cachedStatusCode;
}
}
}
}
}
}
catch (Exception e)
{
result = ServiceResult.Create(
e,
StatusCodes.BadUnexpectedError,
"Failed to read value attribute from node.");
valueToRead = Variant.Null;
sourceTimestamp = DateTimeUtc.MinValue;
}
// commit to the supplied DataValue, mirroring NodeState.ReadAttribute(DataValue).
value.SourceTimestamp = sourceTimestamp;
value.SourcePicoseconds = 0;
if (result != null && result != ServiceResult.Good)
{
value.StatusCode = result.StatusCode;
}
else
{
value.StatusCode = StatusCodes.Good;
}
value.WrappedValue = StatusCode.IsBad(value.StatusCode) ? Variant.Null : valueToRead;
return result;
}
/// <summary>
/// Asynchronous override of
/// <see cref="NodeState.WriteAttributeAsync(ISystemContext, uint, NumericRange, DataValue, CancellationToken)"/>.
/// Dispatches to <see cref="OnWriteValueAsync"/> or
/// <see cref="OnSimpleWriteValueAsync"/> when set; otherwise falls
/// through to the synchronous write flow under <c>lock(this)</c>.
/// </summary>
public override async ValueTask<ServiceResult> WriteAttributeAsync(
ISystemContext context,
uint attributeId,
NumericRange indexRange,
DataValue value,
CancellationToken cancellationToken = default)
{
NodeValueWriteEventHandlerAsync onWriteValueAsync = OnWriteValueAsync;
NodeValueSimpleWriteEventHandlerAsync onSimpleWriteValueAsync = OnSimpleWriteValueAsync;
if (attributeId != Attributes.Value ||
(onWriteValueAsync == null && onSimpleWriteValueAsync == null))
{
return await base.WriteAttributeAsync(
context, attributeId, indexRange, value, cancellationToken)
.ConfigureAwait(false);
}
if (value == null)
{
return ServiceResult.Create(
StatusCodes.BadStructureMissing,
"DataValue missing");
}
if (value.ServerTimestamp != DateTimeUtc.MinValue)
{
return ServiceResult.Create(
StatusCodes.BadWriteNotSupported,
"Cannot write to server timestamp");
}
try
{
// snapshot access levels under the lock.
uint accessLevel;
byte userAccessLevel;
// TODO: introduce a dedicated private lock object on NodeState
// — see the sibling note in ReadAttributeAsync for the rationale.
#pragma warning disable CA2002 // Do not lock on objects with weak identity
lock (this)
#pragma warning restore CA2002
{
accessLevel = m_accessLevel;
userAccessLevel = m_userAccessLevel;
}
if ((accessLevel & AccessLevels.CurrentWrite) == 0)
{
return StatusCodes.BadNotWritable;
}
OnReadUserAccessLevel?.Invoke(context, this, ref userAccessLevel);
if ((userAccessLevel & AccessLevels.CurrentWrite) == 0)
{
return StatusCodes.BadUserAccessDenied;
}
Variant valueToWrite = value.WrappedValue;
StatusCode statusCode = value.StatusCode;
DateTimeUtc sourceTimestamp = value.SourceTimestamp;
if (onWriteValueAsync != null)
{
AttributeWriteResult writeResult = await onWriteValueAsync(
context, this, indexRange, valueToWrite, cancellationToken)
.ConfigureAwait(false);
if (ServiceResult.IsBad(writeResult.Result))
{
return writeResult.Result;
}
DateTimeUtc effectiveTimestamp = sourceTimestamp == DateTimeUtc.MinValue
? DateTimeUtc.Now
: sourceTimestamp;
#pragma warning disable CA2002 // Do not lock on objects with weak identity
lock (this)
#pragma warning restore CA2002
{
m_value = valueToWrite;
m_statusCode = statusCode;
m_timestamp = effectiveTimestamp;
ChangeMasks |= NodeStateChangeMasks.Value;
}
return writeResult.Result;
}
// simple async write path mirrors OnSimpleWriteValue:
// index-range writes are not supported through this hook.
if (!indexRange.IsNull)
{
return StatusCodes.BadIndexRangeInvalid;
}
if (sourceTimestamp == DateTimeUtc.MinValue)
{
sourceTimestamp = DateTimeUtc.Now;
}
if (CopyPolicy is VariableCopyPolicy.CopyOnWrite or VariableCopyPolicy.Always)
{
valueToWrite = CoreUtils.Clone(valueToWrite);
}
AttributeWriteResult simpleResult = await onSimpleWriteValueAsync(
context, this, valueToWrite, cancellationToken)
.ConfigureAwait(false);
if (ServiceResult.IsBad(simpleResult.Result))
{
return simpleResult.Result;
}
#pragma warning disable CA2002 // Do not lock on objects with weak identity
lock (this)
#pragma warning restore CA2002
{
m_value = valueToWrite;
m_statusCode = statusCode;
m_timestamp = sourceTimestamp;
ChangeMasks |= NodeStateChangeMasks.Value;
}
return simpleResult.Result;
}
catch (Exception e)
{
return ServiceResult.Create(
e,
StatusCodes.BadUnexpectedError,
"Failed to write value attribute.");
}
}
private Variant m_value;
private DateTimeUtc m_timestamp;
private bool m_valueTouched;
+172
View File
@@ -32,6 +32,7 @@ using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using System.Xml;
using Opc.Ua.Types;
@@ -3696,6 +3697,50 @@ namespace Opc.Ua
return result;
}
/// <summary>
/// Asynchronous sibling of
/// <see cref="ReadAttribute(ISystemContext, uint, NumericRange, QualifiedName, DataValue)"/>.
/// The default implementation simply wraps the synchronous call
/// inside a <c>lock(this)</c> so behaviour is bit-identical for
/// every <see cref="NodeState"/> that does not override it. Derived
/// types (notably <see cref="BaseVariableState"/>) can override
/// this method to dispatch to true asynchronous read hooks without
/// blocking a thread.
/// </summary>
/// <param name="context">The context for the current operation.</param>
/// <param name="attributeId">The attribute id.</param>
/// <param name="indexRange">The index range.</param>
/// <param name="dataEncoding">The data encoding.</param>
/// <param name="value">The value to populate.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>
/// An instance of the <see cref="ServiceResult"/> containing the
/// status code and diagnostic info for the operation.
/// </returns>
public virtual ValueTask<ServiceResult> ReadAttributeAsync(
ISystemContext context,
uint attributeId,
NumericRange indexRange,
QualifiedName dataEncoding,
DataValue value,
CancellationToken cancellationToken = default)
{
ServiceResult result;
// TODO: introduce a dedicated private lock object on NodeState —
// today's sync flow synchronises through `lock(source)` taken by
// external callers (e.g. CustomNodeManager2.Read), so the async
// path must lock on the same instance to preserve mutual
// exclusion. Switching to a private lock object requires
// updating every external `lock(source)` site.
#pragma warning disable CA2002 // Do not lock on objects with weak identity
lock (this)
#pragma warning restore CA2002
{
result = ReadAttribute(context, attributeId, indexRange, dataEncoding, value);
}
return new ValueTask<ServiceResult>(result);
}
/// <summary>
/// Reads the value for any non-value attribute.
/// </summary>
@@ -3999,6 +4044,40 @@ namespace Opc.Ua
}
}
/// <summary>
/// Asynchronous sibling of
/// <see cref="WriteAttribute(ISystemContext, uint, NumericRange, DataValue)"/>.
/// The default implementation wraps the synchronous call inside a
/// <c>lock(this)</c> so behaviour is bit-identical for every
/// <see cref="NodeState"/> that does not override it. Derived
/// types (notably <see cref="BaseVariableState"/>) can override
/// this method to dispatch to true asynchronous write hooks
/// without blocking a thread.
/// </summary>
/// <param name="context">The context for the current operation.</param>
/// <param name="attributeId">The attribute id.</param>
/// <param name="indexRange">The index range.</param>
/// <param name="value">The value to write.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public virtual ValueTask<ServiceResult> WriteAttributeAsync(
ISystemContext context,
uint attributeId,
NumericRange indexRange,
DataValue value,
CancellationToken cancellationToken = default)
{
ServiceResult result;
// TODO: introduce a dedicated private lock object on NodeState —
// see the sibling note in ReadAttributeAsync for the rationale.
#pragma warning disable CA2002 // Do not lock on objects with weak identity
lock (this)
#pragma warning restore CA2002
{
result = WriteAttribute(context, attributeId, indexRange, value);
}
return new ValueTask<ServiceResult>(result);
}
/// <summary>
/// Write the value for any non-value attribute.
/// </summary>
@@ -5340,6 +5419,99 @@ namespace Opc.Ua
ref StatusCode statusCode,
ref DateTimeUtc timestamp);
/// <summary>
/// Result returned by an asynchronous full Value-attribute read hook
/// (<see cref="NodeValueEventHandlerAsync"/>). Carries the value plus
/// its status code and source timestamp; the framework writes all three
/// onto the supplied <see cref="DataValue"/> when the operation
/// succeeds. Allocated on the stack — no per-call allocation.
/// </summary>
/// <param name="Result">
/// Operation result. <see cref="ServiceResult.IsBad(ServiceResult)"/>
/// short-circuits the rest of the read pipeline.
/// </param>
/// <param name="Value">The value to return to the caller.</param>
/// <param name="StatusCode">Status code to attach to the value.</param>
/// <param name="SourceTimestamp">
/// Source timestamp to attach. <see cref="DateTimeUtc.MinValue"/>
/// causes the framework to substitute the current UTC time, mirroring
/// the synchronous <see cref="NodeValueEventHandler"/> behavior.
/// </param>
public readonly record struct AttributeReadResult(
ServiceResult Result,
Variant Value,
StatusCode StatusCode,
DateTimeUtc SourceTimestamp);
/// <summary>
/// Result returned by an asynchronous simple Value-attribute read hook
/// (<see cref="NodeValueSimpleEventHandlerAsync"/>). Carries only the
/// value; the framework reuses the variable's cached status code and
/// timestamp and runs the standard index-range / data-encoding
/// post-processing — matching the synchronous
/// <see cref="NodeValueSimpleEventHandler"/> pipeline.
/// </summary>
/// <param name="Result">Operation result.</param>
/// <param name="Value">The value to return to the caller.</param>
public readonly record struct AttributeSimpleReadResult(
ServiceResult Result,
Variant Value);
/// <summary>
/// Result returned by an asynchronous Value-attribute write hook
/// (<see cref="NodeValueWriteEventHandlerAsync"/> /
/// <see cref="NodeValueSimpleWriteEventHandlerAsync"/>).
/// </summary>
/// <param name="Result">Operation result.</param>
public readonly record struct AttributeWriteResult(
ServiceResult Result);
/// <summary>
/// Asynchronous sibling of <see cref="NodeValueEventHandler"/> for the
/// read direction. Returns a <see cref="AttributeReadResult"/> instead
/// of writing to <c>ref</c> parameters because <c>ref</c> cannot cross
/// an <c>await</c>.
/// </summary>
public delegate ValueTask<AttributeReadResult> NodeValueEventHandlerAsync(
ISystemContext context,
NodeState node,
NumericRange indexRange,
QualifiedName dataEncoding,
CancellationToken cancellationToken);
/// <summary>
/// Asynchronous sibling of <see cref="NodeValueSimpleEventHandler"/>
/// for the read direction.
/// </summary>
public delegate ValueTask<AttributeSimpleReadResult> NodeValueSimpleEventHandlerAsync(
ISystemContext context,
NodeState node,
CancellationToken cancellationToken);
/// <summary>
/// Asynchronous sibling of <see cref="NodeValueEventHandler"/> for the
/// write direction. The hook receives the value being written and
/// returns only a status; on success the framework updates the
/// variable's cached value and timestamp to mirror the synchronous
/// path.
/// </summary>
public delegate ValueTask<AttributeWriteResult> NodeValueWriteEventHandlerAsync(
ISystemContext context,
NodeState node,
NumericRange indexRange,
Variant value,
CancellationToken cancellationToken);
/// <summary>
/// Asynchronous sibling of <see cref="NodeValueSimpleEventHandler"/>
/// for the write direction.
/// </summary>
public delegate ValueTask<AttributeWriteResult> NodeValueSimpleWriteEventHandlerAsync(
ISystemContext context,
NodeState node,
Variant value,
CancellationToken cancellationToken);
/// <summary>
/// Stores a reference from a node in the instance hierarchy.
/// </summary>