Files
UA-.NETStandard/tests/Opc.Ua.XRegistry.Tests/XRegistryAsyncNodeManagerTests.cs
Marc SchierandCopilot da7412c429 Add native xRegistry events and distinct Resource versions (#4396)
## Summary

- support the xRegistry 0.6.0 distinct Resource/Version hierarchy:
  `Registry -> Group -> Resource -> Versions -> Version`
- instantiate stable logical Resource nodes separately from exact
Version nodes
  while preserving existing Version NodeIds
- delegate logical Resource FileType operations and Version-view
metadata to the
selected default Version with handle pinning and shared writer
reservations
- make Resource and Version delete behavior role-stable and use
`MetaEpoch` /
  `Epoch` respectively
- use stable Resource SourceNodes, exact Version SourceNodes and the
  `Server -> Registry -> Group -> Resource -> Version` notifier chain
- keep a model-metadata-gated client fallback for 0.5 flat Servers; 0.6
Servers
  expose no legacy aliases
- target `HasWoTProjection` at the real stable logical Resource node
- use `AsyncCustomNodeManager` for the registration, fast-path, and
federation managers,
  with awaited lifecycle, node mutations, event delivery, and cleanup
- handle startup rollback, reentrant deletion callbacks, undisclosed
file handles,
  and retryable cleanup without blocking unrelated file operations

## Authoritative dependencies

- companion specification: OPCF-Members/spec-drafts#32 at
  `d3399fca025025a73e5a6b599f850a81d985c060`
- OPC UA binding: xregistry/spec#568
- xRegistry event semantics: xregistry/spec#563, merged as
  `66ee31e47fbadff6ab982ae1516f7d9065e46b64`
- companion NodeSet SHA-256:
  `369f5f3f1814b333e4eeb52bfb4b6a730cdff372505a5f91a54c94f0016ac4cf`

## Review feedback

- keep the WoT Connectivity information-model designation at `1.1`;
draft revisions update the publication date without changing the model
version
- eliminate all 198 unique CA1861 diagnostics across 54 files and remove
seven obsolete project suppressions
- fix the analyzer-clean touched-path CA1307 and the hierarchy follow-up
CA1846/CA1851 diagnostics without dropping legacy-TFM compatibility

- recover the reconcile queue after callback faults/cancellation and
continue accepted pending changes
- remove migration guidance for the xRegistry managers, which are new in
2.0

## Compatibility

- public WoT Connectivity model designation remains `1.1`
- generated event-record ABI, constructors, options, NodeIds, and stored
documents remain compatible
- the generic xRegistry managers are new in 2.0 and implement
`IAsyncNodeManager`;
use `IAsyncNodeManagerFactory` and the async lifecycle hooks (see
`docs\XRegistry.md`)
- persistence schema and document blobs remain compatible unless noted
below

## Validation

- merge validation at `980422f76` with upstream `e8bec27d5`: 454
targeted WoT/materialization/model/registry cases and all 327 XRegistry
cases passed on both net10.0 and net48; all 29 net10 aggregation sample
cases passed
- feedback regressions at `308e8ba74`: 55 targeted queue and live
registry event/projection/lifecycle cases passed on each of net10.0 and
net48
- prior CI: all 266 checks passed at `6489a0908`; the unchanged Ubuntu
Core certificate-counter test and dependent jobs passed on the one-time
retry ([run 34117304035, attempt
2](https://github.com/OPCFoundation/UA-.NETStandard/actions/runs/34117304035/attempts/2))
- `Opc.Ua.XRegistry.Tests` — 327 passed on net10.0 and net48 for
migration commit `32c914aaf`
- `Opc.Ua.WotCon.Tests` net10.0 — 1348 passed
- `Opc.Ua.WotCon.Bindings.Tests` net10.0 — 683 passed
- `Opc.Ua.SourceGeneration.Core.Tests` net10.0 — 3807 passed, 8 skipped
- `Opc.Ua.SourceGeneration.Tests` net10.0 — 145 passed
- `Opc.Ua.SourceGeneration.Stack.Tests` net10.0 — 94 passed
- affected XRegistry and WoT client/server projects build successfully
- xRegistry NodeSet is byte-identical to the companion artifact
- repository-wide CA1861 inventory — 198 unique diagnostics across 54
files reduced to zero
- targeted changed-fixture validation — 1,098 passed, 3 skipped, 0
failed
- constrained full `UA.slnx` net10.0 Release build — CA1861 = 0
- `Opc.Ua.XRegistry.Client` net472 and netstandard2.1 builds — 0
warnings, 0 errors
- independent shared-array safety review — no significant issues
- `git diff --check`

The final review regressions cover concurrent reconcile/open, synthetic
logical
handle collisions across default switches, cross-session Close
isolation,
shared writer reservations, logical FileType property/change
propagation,
logical Labels delegation, role-fixed deletion, event sources,
model-version
gating, and stable `HasWoTProjection` targets.

The companion specification and protocol binding are reviewed in the
linked PRs above.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 5c79a1ed-6dab-4522-a9fc-6548caa78ad5
Copilot-Session: b605797f-a97a-4cd0-8f12-016d33c2fd88
Copilot-Session: 7ef369a5-2307-4f71-8a11-15af0e72f240
Copilot-Session: e716d455-8397-4d22-9c5a-398847ab7317
2026-09-08 04:46:40 +00:00

961 lines
50 KiB
C#

/* ========================================================================
* Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Moq;
using Moq.Protected;
using NUnit.Framework;
using Opc.Ua.Server;
using Opc.Ua.XRegistry.Server;
namespace Opc.Ua.XRegistry.Tests
{
[TestFixture]
[Category("XRegistry")]
public sealed class XRegistryAsyncNodeManagerTests
{
[TestCase("registration")]
[TestCase("fast-path")]
[TestCase("federation")]
public void CancelledStartupPublishesNoNodes(string managerKind)
{
var options = new XRegistryServerOptions();
using AsyncCustomNodeManager manager = CreateManager(managerKind, options);
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
Assert.That(
async () => await manager.CreateAddressSpaceAsync(
new Dictionary<NodeId, IList<IReference>>(),
cancellation.Token).ConfigureAwait(false),
Throws.InstanceOf<OperationCanceledException>());
Assert.That(
manager.Find(ExpandedNodeId.ToNodeId(
ObjectTypeIds.RegistryType,
manager.SystemContext.NamespaceUris)),
Is.Null);
}
[TestCase("registration")]
[TestCase("fast-path")]
[TestCase("federation")]
public async Task InterruptedRecursiveStartupRollsBackPublishedNodes(string managerKind)
{
var options = new XRegistryServerOptions();
IServerInternal server = XRegistryServerTestHarness.CreateServer(options.RegistryNamespaceUri).Object;
var entered = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
bool interrupt = true;
RegistryState? partialRegistry = null;
using AsyncCustomNodeManager manager = managerKind switch
{
"registration" => CreateInterceptedManager<XRegistryRegistrationNodeManager>(
server, options, BeforeAdd),
"fast-path" => CreateInterceptedManager<XRegistryFastPathNodeManager>(server, options, BeforeAdd),
"federation" => CreateInterceptedManager<XRegistryFederationNodeManager>(server, options, BeforeAdd),
_ => throw new ArgumentOutOfRangeException(nameof(managerKind))
};
var existingReference = new NodeStateReference(
ReferenceTypeIds.HasNotifier, false, new NodeId("another-registry", 1));
var externalReferences = new Dictionary<NodeId, IList<IReference>>
{
[Ua.ObjectIds.Server] = [existingReference]
};
using var cancellation = new CancellationTokenSource();
Task startup = manager.CreateAddressSpaceAsync(externalReferences, cancellation.Token).AsTask();
await entered.Task.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false);
var registryTypeId = ExpandedNodeId.ToNodeId(
ObjectTypeIds.RegistryType, manager.SystemContext.NamespaceUris);
Assert.That(manager.Find(registryTypeId), Is.Not.Null);
cancellation.Cancel();
Assert.That(() => startup, Throws.InstanceOf<OperationCanceledException>());
Assert.Multiple(() =>
{
Assert.That(manager.Find(registryTypeId), Is.Null);
Assert.That(externalReferences, Has.Count.EqualTo(1));
Assert.That(externalReferences[Ua.ObjectIds.Server], Has.Count.EqualTo(1));
Assert.That(externalReferences[Ua.ObjectIds.Server][0], Is.SameAs(existingReference));
if (partialRegistry is not null)
{
Assert.That(manager.Find(partialRegistry.NodeId), Is.Null);
}
});
if (partialRegistry is not null)
{
Assert.That(
async () => await partialRegistry.GetOrCreateGroup!.OnCallAsync!(
manager.SystemContext, partialRegistry.GetOrCreateGroup, partialRegistry.NodeId,
"not-initialized", CancellationToken.None).ConfigureAwait(false),
Throws.TypeOf<ServiceResultException>());
}
interrupt = false;
await manager.CreateAddressSpaceAsync(externalReferences).ConfigureAwait(false);
Assert.That(manager.Find(registryTypeId), Is.Not.Null);
if (manager is XRegistryRegistrationNodeManager registration)
{
Assert.That(RegistryOf(registration), Is.Not.Null);
}
async ValueTask<NodeState> BeforeAdd(NodeState node, CancellationToken ct)
{
bool shouldInterrupt = managerKind == "registration"
? node is BaseInstanceState { Parent: RegistryState }
: node is BaseInstanceState { Parent: BaseObjectTypeState parent } &&
parent.BrowseName.Name == "RegistryType";
if (interrupt && shouldInterrupt)
{
partialRegistry = (node as BaseInstanceState)?.Parent as RegistryState;
entered.TrySetResult(true);
await Task.Delay(Timeout.Infinite, ct).ConfigureAwait(false);
}
return node;
}
}
[TestCase(false)]
[TestCase(true)]
public async Task DeletionNotificationsCanReenterTheRegistry(bool attributeDeletion)
{
using XRegistryRegistrationNodeManager manager = await CreateRegistrationAsync(new InMemoryResourceStore())
.ConfigureAwait(false);
ResourceState resource = await CreateCommittedResourceAsync(manager).ConfigureAwait(false);
RegistryState registry = RegistryOf(manager);
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
bool notified = false;
NodeState target = resource;
if (attributeDeletion)
{
await manager.OnAddAttributeAsync(resource.Labels!, resource.Epoch, "to-delete", "value", 0)
.ConfigureAwait(false);
target = resource.Labels!.FindChild(manager.SystemContext,
new QualifiedName("to-delete", resource.NodeId.NamespaceIndex))!;
}
target.OnStateChangedAsync = async (context, node, changes, ct) =>
{
if ((changes & NodeStateChangeMasks.Deleted) != 0)
{
GetOrCreateGroupMethodStateResult result = await registry.GetOrCreateGroup!.OnCallAsync!(
manager.SystemContext, registry.GetOrCreateGroup, registry.NodeId, "schemas", timeout.Token)
.ConfigureAwait(false);
Assert.That(ServiceResult.IsGood(result.ServiceResult), Is.True);
notified = true;
}
};
ServiceResult result;
if (attributeDeletion)
{
RemoveAttributeMethodStateResult deleted = await manager.OnRemoveAttributeAsync(
resource.Labels!, resource.Epoch, "to-delete", 0, cancellationToken: timeout.Token)
.ConfigureAwait(false);
result = deleted.ServiceResult;
}
else
{
DeleteMethodStateResult deleted = await manager.OnDeleteResourceAsync(resource, 0, timeout.Token)
.ConfigureAwait(false);
result = deleted.ServiceResult;
}
Assert.Multiple(() =>
{
Assert.That(ServiceResult.IsGood(result), Is.True);
Assert.That(notified, Is.True);
Assert.That(manager.Find(target.NodeId), Is.Null);
});
}
[Test]
public async Task RetiringFastPathCannotDeleteItsReentrantReplacement()
{
using XRegistryRegistrationNodeManager manager = await CreateRegistrationAsync(new InMemoryResourceStore())
.ConfigureAwait(false);
ResourceState resource = await CreateCommittedResourceAsync(manager).ConfigureAwait(false);
var contentId = new NodeId(ByteString.From([1, 2, 3, 4]), resource.NodeId.NamespaceIndex);
NodeState previous = manager.Find(contentId)!;
ResourceState? replacement = null;
previous.OnStateChangedAsync = async (context, node, changes, ct) =>
{
if ((changes & NodeStateChangeMasks.Deleted) != 0)
{
replacement = await CreateCommittedResourceAsync(manager, "replacement").ConfigureAwait(false);
}
};
DeleteMethodStateResult deleted = await manager.OnDeleteResourceAsync(resource, 0)
.AsTask().WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false);
Assert.Multiple(() =>
{
Assert.That(ServiceResult.IsGood(deleted.ServiceResult), Is.True);
Assert.That(replacement, Is.Not.Null);
Assert.That(manager.Find(contentId), Is.Not.Null.And.Not.SameAs(previous));
Assert.That(manager.Find(resource.NodeId), Is.Null);
});
await manager.OnDeleteResourceAsync(replacement!, 0).ConfigureAwait(false);
Assert.That(manager.Find(contentId), Is.Null);
}
[TestCase(false)]
[TestCase(true)]
public async Task RetiringResourceCannotAllocateAnotherHandle(bool writing)
{
using XRegistryRegistrationNodeManager manager = await CreateRegistrationAsync(new InMemoryResourceStore())
.ConfigureAwait(false);
ResourceState resource = await CreateCommittedResourceAsync(manager).ConfigureAwait(false);
NodeState fastPath = manager.Find(new NodeId(
ByteString.From([1, 2, 3, 4]), resource.NodeId.NamespaceIndex))!;
OpenMethodStateResult opened = new();
AddAttributeMethodStateResult attribute = new();
bool notified = false;
AttributesState labels = resource.Labels!;
fastPath.OnStateChangedAsync = async (context, node, changes, ct) =>
{
if ((changes & NodeStateChangeMasks.Deleted) != 0)
{
opened = await resource.Open!.OnCallAsync!(
manager.SystemContext, resource.Open, resource.NodeId, writing ? (byte)2 : (byte)1,
CancellationToken.None).ConfigureAwait(false);
attribute = await labels.AddAttribute!.OnCallAsync!(
manager.SystemContext, labels.AddAttribute, labels.NodeId, "late", "value", 0,
CancellationToken.None).ConfigureAwait(false);
notified = true;
}
};
await manager.OnDeleteResourceAsync(resource, 0).ConfigureAwait(false);
Assert.That(notified, Is.True);
Assert.That(opened.ServiceResult.StatusCode.Code, Is.EqualTo(StatusCodes.BadNodeIdUnknown));
Assert.That(opened.FileHandle, Is.Zero);
Assert.That(attribute.ServiceResult.StatusCode.Code, Is.EqualTo(StatusCodes.BadNodeIdUnknown));
ResourceState replacement = await CreateCommittedResourceAsync(manager, "replacement")
.ConfigureAwait(false);
Assert.That(manager.Find(replacement.NodeId), Is.SameAs(replacement));
}
[TestCase("fast-path")]
[TestCase("resource")]
[TestCase("child")]
public async Task FailingDeletionNotificationDoesNotAbandonCommittedCleanup(string failureTarget)
{
var contents = new InMemoryResourceStore();
using XRegistryRegistrationNodeManager manager = await CreateRegistrationAsync(contents)
.ConfigureAwait(false);
ResourceState first = await CreateCommittedResourceAsync(manager, "a").ConfigureAwait(false);
ResourceState second = await CreateCommittedResourceAsync(manager, "b", ByteString.From([5, 6, 7, 8]))
.ConfigureAwait(false);
var group = (GroupState)first.Parent!;
var originalNodes = new List<NodeState>();
CollectNodes(group, manager.SystemContext, originalNodes);
NodeState fastPath = manager.Find(new NodeId(
ByteString.From([1, 2, 3, 4]), first.NodeId.NamespaceIndex))!;
originalNodes.Add(fastPath);
originalNodes.Add(manager.Find(new NodeId(
ByteString.From([5, 6, 7, 8]), second.NodeId.NamespaceIndex))!);
NodeState failure = failureTarget switch
{
"resource" => first,
"child" => first.OpenCount!,
_ => fastPath
};
failure.OnStateChangedAsync = (_, _, changes, _) =>
(changes & NodeStateChangeMasks.Deleted) == 0
? default
: throw new InvalidOperationException("Injected deletion notification failure.");
Assert.That(
async () => await manager.OnDeleteGroupAsync(group, 0).ConfigureAwait(false),
Throws.InvalidOperationException.With.Message.EqualTo("Injected deletion notification failure."));
foreach (NodeState node in originalNodes)
{
Assert.That(manager.Find(node.NodeId), Is.Null, $"Node {node.NodeId} was left behind.");
}
Assert.That(
(await contents.ReadAsync(first.NodeId.ToString(), 0, 8).ConfigureAwait(false)).IsNull, Is.True);
Assert.That(
(await contents.ReadAsync(second.NodeId.ToString(), 0, 8).ConfigureAwait(false)).IsNull, Is.True);
var remaining = new List<BaseInstanceState>();
RegistryOf(manager).GetChildren(manager.SystemContext, remaining);
Assert.That(remaining, Does.Not.Contain(group));
}
[TestCase(false)]
[TestCase(true)]
public async Task FailedStoreDeletionIsRetainedForRetry(bool deleteGroup)
{
var contents = new InMemoryResourceStore();
Mock<IXRegistryResourceStore> store = CreateStore(contents);
using XRegistryRegistrationNodeManager manager = await CreateRegistrationAsync(store.Object)
.ConfigureAwait(false);
ResourceState resource = await CreateCommittedResourceAsync(manager).ConfigureAwait(false);
string storeKey = resource.NodeId.ToString();
var group = (GroupState)resource.Parent!;
bool fail = true;
store.Setup(s => s.DeleteAsync(storeKey, It.IsAny<CancellationToken>()))
.Returns((string key, CancellationToken ct) => fail
? throw new IOException("Injected store deletion failure.")
: contents.DeleteAsync(key, ct));
Assert.That(
() => deleteGroup
? manager.OnDeleteGroupAsync(group, 0).AsTask()
: manager.OnDeleteResourceAsync(resource, 0).AsTask(),
Throws.TypeOf<IOException>().With.Message.EqualTo("Injected store deletion failure."));
Assert.That(manager.Find(resource.NodeId), Is.Null);
Assert.That((await contents.ReadAsync(storeKey, 0, 4).ConfigureAwait(false)).IsNull, Is.False);
fail = false;
uint epoch = RegistryOf(manager).Epoch!.Value;
DeleteMethodStateResult retried = deleteGroup
? await manager.OnDeleteGroupAsync(group, 0).ConfigureAwait(false)
: await manager.OnDeleteResourceAsync(resource, 0).ConfigureAwait(false);
Assert.That(ServiceResult.IsGood(retried.ServiceResult), Is.True);
Assert.That((await contents.ReadAsync(storeKey, 0, 4).ConfigureAwait(false)).IsNull, Is.True);
Assert.That(RegistryOf(manager).Epoch!.Value, Is.EqualTo(epoch));
}
[TestCase("open")]
[TestCase("create")]
[TestCase("close")]
public async Task RetainedCleanupFailureCannotInterruptAnotherUpload(string operation)
{
var contents = new InMemoryResourceStore();
Mock<IXRegistryResourceStore> store = CreateStore(contents);
using XRegistryRegistrationNodeManager manager = await CreateRegistrationAsync(store.Object)
.ConfigureAwait(false);
ResourceState deleted = await CreateCommittedResourceAsync(manager, "a").ConfigureAwait(false);
ResourceState target = await CreateCommittedResourceAsync(manager, "b", ByteString.From([5, 6, 7, 8]))
.ConfigureAwait(false);
var group = (GroupState)target.Parent!;
uint handle = 0;
if (operation == "close")
{
OpenMethodStateResult opened = await target.Open!.OnCallAsync!(
manager.SystemContext, target.Open, target.NodeId, 2, CancellationToken.None).ConfigureAwait(false);
handle = opened.FileHandle;
await target.Write!.OnCallAsync!(
manager.SystemContext, target.Write, target.NodeId, handle, ByteString.From([9, 9]),
CancellationToken.None).ConfigureAwait(false);
}
string deletedKey = deleted.NodeId.ToString();
bool fail = true;
store.Setup(s => s.DeleteAsync(deletedKey, It.IsAny<CancellationToken>()))
.Returns((string key, CancellationToken ct) => fail
? throw new IOException("Injected retained cleanup failure.")
: contents.DeleteAsync(key, ct));
Assert.That(
async () => await manager.OnDeleteResourceAsync(deleted, 0).ConfigureAwait(false),
Throws.TypeOf<IOException>());
if (operation == "create")
{
CreateResourceMethodStateResult created = await group.CreateResource!.OnCallAsync!(
manager.SystemContext, group.CreateResource, group.NodeId, "c", "v1", true,
CancellationToken.None).ConfigureAwait(false);
Assert.That(ServiceResult.IsGood(created.ServiceResult), Is.True);
target = (ResourceState)manager.Find(created.ResourceNodeId)!;
handle = created.FileHandle;
}
else if (operation == "open")
{
OpenMethodStateResult opened = await target.Open!.OnCallAsync!(
manager.SystemContext, target.Open, target.NodeId, 2, CancellationToken.None).ConfigureAwait(false);
Assert.That(ServiceResult.IsGood(opened.ServiceResult), Is.True);
handle = opened.FileHandle;
}
if (operation != "close")
{
await target.Write!.OnCallAsync!(
manager.SystemContext, target.Write, target.NodeId, handle, ByteString.From([9, 9]),
CancellationToken.None).ConfigureAwait(false);
}
CloseMethodStateResult closed = await target.Close!.OnCallAsync!(
manager.SystemContext, target.Close, target.NodeId, handle, CancellationToken.None)
.ConfigureAwait(false);
Assert.That(ServiceResult.IsGood(closed.ServiceResult), Is.True);
ByteString bytes = await contents.ReadAsync(target.NodeId.ToString(), 0, 8).ConfigureAwait(false);
Assert.That(bytes, Is.EqualTo(operation == "create"
? ByteString.From([9, 9])
: ByteString.From([9, 9, 7, 8])));
fail = false;
await manager.OnDeleteResourceAsync(deleted, 0).ConfigureAwait(false);
Assert.That((await contents.ReadAsync(deletedKey, 0, 8).ConfigureAwait(false)).IsNull, Is.True);
OpenMethodStateResult reopened = await target.Open!.OnCallAsync!(
manager.SystemContext, target.Open, target.NodeId, 2, CancellationToken.None).ConfigureAwait(false);
Assert.That(ServiceResult.IsGood(reopened.ServiceResult), Is.True);
}
[Test]
public async Task MultipleDeletionFailuresAreReportedAfterAllNodesAreRemoved()
{
var contents = new InMemoryResourceStore();
using XRegistryRegistrationNodeManager manager = await CreateRegistrationAsync(contents)
.ConfigureAwait(false);
ResourceState first = await CreateCommittedResourceAsync(manager, "a").ConfigureAwait(false);
ResourceState second = await CreateCommittedResourceAsync(manager, "b", ByteString.From([5, 6, 7, 8]))
.ConfigureAwait(false);
var group = (GroupState)first.Parent!;
NodeState firstFastPath = manager.Find(new NodeId(
ByteString.From([1, 2, 3, 4]), first.NodeId.NamespaceIndex))!;
NodeState secondFastPath = manager.Find(new NodeId(
ByteString.From([5, 6, 7, 8]), second.NodeId.NamespaceIndex))!;
firstFastPath.OnStateChangedAsync = (_, _, _, _) =>
throw new InvalidOperationException("First sink failed.");
secondFastPath.OnStateChangedAsync = (_, _, _, _) =>
throw new InvalidOperationException("Second sink failed.");
Assert.That(
async () => await manager.OnDeleteGroupAsync(group, 0).ConfigureAwait(false),
Throws.TypeOf<AggregateException>().With.Property("InnerExceptions").Count.EqualTo(2));
Assert.Multiple(() =>
{
Assert.That(manager.Find(first.NodeId), Is.Null);
Assert.That(manager.Find(second.NodeId), Is.Null);
Assert.That(manager.Find(group.NodeId), Is.Null);
Assert.That(manager.Find(firstFastPath.NodeId), Is.Null);
Assert.That(manager.Find(secondFastPath.NodeId), Is.Null);
});
Assert.That(
(await contents.ReadAsync(first.NodeId.ToString(), 0, 4).ConfigureAwait(false)).IsNull, Is.True);
Assert.That(
(await contents.ReadAsync(second.NodeId.ToString(), 0, 4).ConfigureAwait(false)).IsNull, Is.True);
}
[TestCase(false)]
[TestCase(true)]
public async Task FailedHandleNotificationReleasesTheUndisclosedReservation(bool duringCreation)
{
using XRegistryRegistrationNodeManager manager = await CreateRegistrationAsync(new InMemoryResourceStore())
.ConfigureAwait(false);
ResourceState resource = await CreateCommittedResourceAsync(manager).ConfigureAwait(false);
var group = (GroupState)resource.Parent!;
NodeState changedNode = duringCreation ? group : resource.OpenCount!;
changedNode.OnStateChangedAsync = (_, _, _, _) =>
throw new InvalidOperationException("Injected notification failure.");
if (duringCreation)
{
Assert.That(
async () => await group.CreateResource!.OnCallAsync!(
manager.SystemContext, group.CreateResource, group.NodeId,
"undisclosed", "v1", true, CancellationToken.None).ConfigureAwait(false),
Throws.InvalidOperationException.With.Message.EqualTo("Injected notification failure."));
}
else
{
Assert.That(
async () => await resource.Open!.OnCallAsync!(
manager.SystemContext, resource.Open, resource.NodeId, 2, CancellationToken.None)
.ConfigureAwait(false),
Throws.InvalidOperationException.With.Message.EqualTo("Injected notification failure."));
}
changedNode.OnStateChangedAsync = null;
OpenMethodStateResult opened = await resource.Open!.OnCallAsync!(
manager.SystemContext, resource.Open, resource.NodeId, 2, CancellationToken.None).ConfigureAwait(false);
Assert.That(ServiceResult.IsGood(opened.ServiceResult), Is.True);
CloseMethodStateResult closed = await resource.Close!.OnCallAsync!(
manager.SystemContext, resource.Close, resource.NodeId, opened.FileHandle, CancellationToken.None)
.ConfigureAwait(false);
Assert.That(ServiceResult.IsGood(closed.ServiceResult), Is.True);
Assert.That(resource.OpenCount!.Value, Is.Zero);
}
[Test]
public async Task CancelledBaselineReadReleasesTheWriterReservation()
{
var contents = new InMemoryResourceStore();
Mock<IXRegistryResourceStore> store = CreateStore(contents);
using XRegistryRegistrationNodeManager manager = await CreateRegistrationAsync(store.Object)
.ConfigureAwait(false);
ResourceState resource = await CreateCommittedResourceAsync(manager).ConfigureAwait(false);
var entered = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
store.Setup(s => s.ReadAsync(
It.IsAny<string>(), It.IsAny<long>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
.Returns(async (string key, long offset, int count, CancellationToken ct) =>
{
entered.TrySetResult(true);
await Task.Delay(Timeout.Infinite, ct).ConfigureAwait(false);
return ByteString.Empty;
});
using var cancellation = new CancellationTokenSource();
Task<OpenMethodStateResult> opening = resource.Open!.OnCallAsync!(
manager.SystemContext, resource.Open, resource.NodeId, 2, cancellation.Token).AsTask();
await entered.Task.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false);
cancellation.Cancel();
Assert.That(() => opening,
Throws.InstanceOf<OperationCanceledException>());
store.Setup(s => s.ReadAsync(
It.IsAny<string>(), It.IsAny<long>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
.Returns((string key, long offset, int count, CancellationToken ct) =>
contents.ReadAsync(key, offset, count, ct));
OpenMethodStateResult reopened = await resource.Open.OnCallAsync(
manager.SystemContext, resource.Open, resource.NodeId, 2, CancellationToken.None)
.ConfigureAwait(false);
Assert.That(ServiceResult.IsGood(reopened.ServiceResult), Is.True);
CloseMethodStateResult closed = await resource.Close!.OnCallAsync!(
manager.SystemContext, resource.Close, resource.NodeId, reopened.FileHandle, CancellationToken.None)
.ConfigureAwait(false);
Assert.That(ServiceResult.IsGood(closed.ServiceResult), Is.True);
Assert.That(resource.OpenCount!.Value, Is.Zero);
}
[Test]
public async Task CancelledReadDoesNotConsumeItsReservedRange()
{
var contents = new InMemoryResourceStore();
Mock<IXRegistryResourceStore> store = CreateStore(contents);
using XRegistryRegistrationNodeManager manager = await CreateRegistrationAsync(store.Object)
.ConfigureAwait(false);
ResourceState resource = await CreateCommittedResourceAsync(manager).ConfigureAwait(false);
OpenMethodStateResult opened = await resource.Open!.OnCallAsync!(
manager.SystemContext, resource.Open, resource.NodeId, 1, CancellationToken.None)
.ConfigureAwait(false);
var entered = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
store.Setup(s => s.ReadAsync(
It.IsAny<string>(), It.IsAny<long>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
.Returns(async (string key, long offset, int count, CancellationToken ct) =>
{
entered.TrySetResult(true);
await Task.Delay(Timeout.Infinite, ct).ConfigureAwait(false);
return ByteString.Empty;
});
using var cancellation = new CancellationTokenSource();
Task<ReadMethodStateResult> reading = resource.Read!.OnCallAsync!(
manager.SystemContext, resource.Read, resource.NodeId, opened.FileHandle, 2, cancellation.Token)
.AsTask();
await entered.Task.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false);
cancellation.Cancel();
Assert.That(() => reading,
Throws.InstanceOf<OperationCanceledException>());
store.Setup(s => s.ReadAsync(
It.IsAny<string>(), It.IsAny<long>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
.Returns((string key, long offset, int count, CancellationToken ct) =>
contents.ReadAsync(key, offset, count, ct));
ReadMethodStateResult retried = await resource.Read.OnCallAsync(
manager.SystemContext, resource.Read, resource.NodeId, opened.FileHandle, 2, CancellationToken.None)
.ConfigureAwait(false);
Assert.That(ServiceResult.IsGood(retried.ServiceResult), Is.True);
Assert.That(retried.Data, Is.EqualTo(ByteString.From([1, 2])));
}
[TestCase(false)]
[TestCase(true)]
public async Task TeardownDrainsCloseAndPreservesTheInjectedStore(bool synchronousDispose)
{
var contents = new InMemoryResourceStore();
Mock<IXRegistryResourceStore> store = CreateStore(contents);
using XRegistryRegistrationNodeManager manager = await CreateRegistrationAsync(store.Object)
.ConfigureAwait(false);
ResourceState resource = await CreateCommittedResourceAsync(manager).ConfigureAwait(false);
OpenMethodStateResult opened = await resource.Open!.OnCallAsync!(
manager.SystemContext, resource.Open, resource.NodeId, 2, CancellationToken.None)
.ConfigureAwait(false);
await resource.Write!.OnCallAsync!(
manager.SystemContext, resource.Write, resource.NodeId, opened.FileHandle,
ByteString.From([5, 6]), CancellationToken.None).ConfigureAwait(false);
var entered = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var release = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
store.Setup(s => s.WriteAsync(
It.IsAny<string>(), It.IsAny<long>(), It.IsAny<ByteString>(), It.IsAny<CancellationToken>()))
.Returns(async (string key, long offset, ByteString data, CancellationToken ct) =>
{
entered.TrySetResult(true);
await release.Task.WaitAsync(TimeSpan.FromSeconds(10), ct).ConfigureAwait(false);
await contents.WriteAsync(key, offset, data, ct).ConfigureAwait(false);
});
Task<CloseMethodStateResult> closing = resource.Close!.OnCallAsync!(
manager.SystemContext, resource.Close, resource.NodeId, opened.FileHandle, CancellationToken.None)
.AsTask();
await entered.Task.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false);
Task teardown;
if (synchronousDispose)
{
manager.Dispose();
teardown = Task.CompletedTask;
}
else
{
teardown = manager.DeleteAddressSpaceAsync().AsTask();
}
try
{
Assert.That(closing.IsCompleted, Is.False);
Assert.That(teardown.IsCompleted, Is.EqualTo(synchronousDispose));
}
finally
{
release.TrySetResult(true);
}
CloseMethodStateResult closed = await closing.ConfigureAwait(false);
await teardown.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false);
ByteString persisted = await store.Object.ReadAsync(resource.NodeId.ToString(), 0, 4)
.ConfigureAwait(false);
Assert.Multiple(() =>
{
Assert.That(ServiceResult.IsGood(closed.ServiceResult), Is.True);
Assert.That(manager.Find(resource.NodeId), Is.Null);
Assert.That(persisted, Is.EqualTo(ByteString.From([5, 6, 3, 4])));
});
}
[Test]
public async Task StartupAwaitsAnAsynchronousModelLoader()
{
var options = new XRegistryServerOptions();
using var manager = new DelayedRegistrationNodeManager(
XRegistryServerTestHarness.CreateServer(options.RegistryNamespaceUri).Object,
options);
Task startup = manager.CreateAddressSpaceAsync(
new Dictionary<NodeId, IList<IReference>>()).AsTask();
await manager.LoadEntered.Task.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false);
try
{
Assert.That(startup.IsCompleted, Is.False);
Assert.That(manager.Find(new NodeId(
XRegistryWellKnown.RegistryObject,
manager.SystemContext.NamespaceUris.GetIndexOrAppend(options.RegistryNamespaceUri))), Is.Null);
}
finally
{
manager.ReleaseLoad.TrySetResult(true);
}
await startup.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false);
Assert.That(RegistryOf(manager), Is.Not.Null);
}
[Test]
public async Task CancellingAQueuedMutationLeavesTheGraphUnchanged()
{
var options = new XRegistryServerOptions();
using var manager = new DelayedRegistrationNodeManager(
XRegistryServerTestHarness.CreateServer(options.RegistryNamespaceUri).Object,
options);
manager.ReleaseLoad.TrySetResult(true);
await manager.CreateAddressSpaceAsync(new Dictionary<NodeId, IList<IReference>>())
.ConfigureAwait(false);
RegistryState registry = RegistryOf(manager);
Task<CreateGroupMethodStateResult> first = registry.CreateGroup!.OnCallAsync!(
manager.SystemContext, registry.CreateGroup, registry.NodeId, "held", CancellationToken.None)
.AsTask();
await manager.GroupEntered.Task.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false);
using var cancellation = new CancellationTokenSource();
Task<CreateGroupMethodStateResult> queued = registry.CreateGroup.OnCallAsync(
manager.SystemContext, registry.CreateGroup, registry.NodeId, "cancelled", cancellation.Token)
.AsTask();
try
{
Assert.That(queued.IsCompleted, Is.False);
cancellation.Cancel();
Assert.That(() => queued, Throws.InstanceOf<OperationCanceledException>());
}
finally
{
manager.ReleaseGroup.TrySetResult(true);
}
CreateGroupMethodStateResult completed = await first.ConfigureAwait(false);
var children = new List<BaseInstanceState>();
registry.GetChildren(manager.SystemContext, children);
Assert.Multiple(() =>
{
Assert.That(ServiceResult.IsGood(completed.ServiceResult), Is.True);
Assert.That(children.Exists(node => node is GroupState group &&
group.GroupId?.Value == "cancelled"), Is.False);
});
}
[Test]
public async Task SessionClosingRefreshesOpenCountWithoutClosingOtherSessions()
{
using XRegistryRegistrationNodeManager manager = await CreateRegistrationAsync(new InMemoryResourceStore())
.ConfigureAwait(false);
ResourceState resource = await CreateCommittedResourceAsync(manager).ConfigureAwait(false);
var firstContext = new Mock<ISystemContext>();
var firstSessionId = new NodeId("first-session", 1);
firstContext.As<ISessionSystemContext>().Setup(c => c.SessionId).Returns(firstSessionId);
var secondContext = new Mock<ISystemContext>();
secondContext.As<ISessionSystemContext>().Setup(c => c.SessionId).Returns(new NodeId("second-session", 1));
OpenMethodStateResult first = await resource.Open!.OnCallAsync!(
firstContext.Object, resource.Open, resource.NodeId, 1, CancellationToken.None).ConfigureAwait(false);
OpenMethodStateResult second = await resource.Open.OnCallAsync(
secondContext.Object, resource.Open, resource.NodeId, 1, CancellationToken.None).ConfigureAwait(false);
Assert.That(resource.OpenCount!.Value, Is.EqualTo(2));
await manager.SessionClosingAsync(null!, firstSessionId, deleteSubscriptions: true)
.ConfigureAwait(false);
ReadMethodStateResult stillOpen = await resource.Read!.OnCallAsync!(
secondContext.Object, resource.Read, resource.NodeId, second.FileHandle, 4, CancellationToken.None)
.ConfigureAwait(false);
ReadMethodStateResult removed = await resource.Read.OnCallAsync(
firstContext.Object, resource.Read, resource.NodeId, first.FileHandle, 4, CancellationToken.None)
.ConfigureAwait(false);
Assert.Multiple(() =>
{
Assert.That(resource.OpenCount.Value, Is.EqualTo(1));
Assert.That(ServiceResult.IsGood(stillOpen.ServiceResult), Is.True);
Assert.That(stillOpen.Data, Is.EqualTo(ByteString.From([1, 2, 3, 4])));
Assert.That(removed.ServiceResult.StatusCode.Code, Is.EqualTo(StatusCodes.BadInvalidState));
});
}
[Test]
public async Task FilePropertiesAwaitAsynchronousChangeNotifications()
{
using XRegistryRegistrationNodeManager manager = await CreateRegistrationAsync(new InMemoryResourceStore())
.ConfigureAwait(false);
ResourceState resource = await CreateCommittedResourceAsync(manager).ConfigureAwait(false);
var observed = new List<ushort>();
resource.OpenCount!.OnStateChangedAsync = async (context, node, changes, ct) =>
{
await Task.Yield();
observed.Add(resource.OpenCount.Value);
};
OpenMethodStateResult opened = await resource.Open!.OnCallAsync!(
manager.SystemContext, resource.Open, resource.NodeId, 1, CancellationToken.None)
.ConfigureAwait(false);
Assert.That(observed, Has.Count.EqualTo(1));
await resource.Close!.OnCallAsync!(
manager.SystemContext, resource.Close, resource.NodeId, opened.FileHandle, CancellationToken.None)
.ConfigureAwait(false);
Assert.Multiple(() =>
{
Assert.That(observed, Has.Count.EqualTo(2));
Assert.That(observed[0], Is.EqualTo(1));
Assert.That(observed[1], Is.Zero);
});
}
[Test]
public async Task AwaitingAnEventSinkDoesNotHoldTheMutationGate()
{
using XRegistryRegistrationNodeManager manager = await CreateRegistrationAsync(
new InMemoryResourceStore(), eventsEnabled: true).ConfigureAwait(false);
RegistryState registry = RegistryOf(manager);
var entered = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var release = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
int reports = 0;
registry.OnReportEventAsync = async (context, node, evt, ct) =>
{
if (Interlocked.Increment(ref reports) == 1)
{
entered.TrySetResult(true);
await release.Task.WaitAsync(TimeSpan.FromSeconds(10), ct).ConfigureAwait(false);
}
};
Task<CreateGroupMethodStateResult> first = registry.CreateGroup!.OnCallAsync!(
manager.SystemContext, registry.CreateGroup, registry.NodeId, "first", CancellationToken.None)
.AsTask();
await entered.Task.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false);
try
{
Assert.That(first.IsCompleted, Is.False);
CreateGroupMethodStateResult second = await registry.CreateGroup.OnCallAsync(
manager.SystemContext, registry.CreateGroup, registry.NodeId, "second", CancellationToken.None)
.AsTask().WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false);
Assert.That(ServiceResult.IsGood(second.ServiceResult), Is.True);
Assert.That(manager.Find(second.GroupNodeId), Is.Not.Null);
}
finally
{
release.TrySetResult(true);
}
CreateGroupMethodStateResult completed = await first.ConfigureAwait(false);
Assert.That(ServiceResult.IsGood(completed.ServiceResult), Is.True);
Assert.That(manager.Find(completed.GroupNodeId), Is.Not.Null);
}
private static async Task<XRegistryRegistrationNodeManager> CreateRegistrationAsync(
IXRegistryResourceStore store,
bool eventsEnabled = false)
{
var options = new XRegistryServerOptions
{
ResourceStore = store,
ContentIdProvider = new XRegistryServerTestHarness.FakeContentIdProvider(),
MaxConcurrentUploads = 1,
EventsEnabled = eventsEnabled,
EventSourceUrl = "https://registry.example.test"
};
var manager = (XRegistryRegistrationNodeManager)CreateManager("registration", options);
await manager.CreateAddressSpaceAsync(new Dictionary<NodeId, IList<IReference>>())
.ConfigureAwait(false);
return manager;
}
private static async Task<ResourceState> CreateCommittedResourceAsync(
XRegistryRegistrationNodeManager manager,
string resourceId = "document",
ByteString document = default)
{
RegistryState registry = RegistryOf(manager);
GetOrCreateGroupMethodStateResult createdGroup = await registry.GetOrCreateGroup!.OnCallAsync!(
manager.SystemContext, registry.GetOrCreateGroup, registry.NodeId, "schemas", CancellationToken.None)
.ConfigureAwait(false);
var group = (GroupState)manager.Find(createdGroup.GroupNodeId)!;
CreateResourceMethodStateResult created = await group.CreateResource!.OnCallAsync!(
manager.SystemContext, group.CreateResource, group.NodeId, resourceId, "v1", true,
CancellationToken.None).ConfigureAwait(false);
var resource = (ResourceState)manager.Find(created.ResourceNodeId)!;
WriteMethodStateResult written = await resource.Write!.OnCallAsync!(
manager.SystemContext, resource.Write, resource.NodeId, created.FileHandle,
document.IsNull ? ByteString.From([1, 2, 3, 4]) : document, CancellationToken.None)
.ConfigureAwait(false);
CloseMethodStateResult closed = await resource.Close!.OnCallAsync!(
manager.SystemContext, resource.Close, resource.NodeId, created.FileHandle,
CancellationToken.None).ConfigureAwait(false);
Assert.Multiple(() =>
{
Assert.That(ServiceResult.IsGood(written.ServiceResult), Is.True);
Assert.That(ServiceResult.IsGood(closed.ServiceResult), Is.True);
});
return resource;
}
private static void CollectNodes(NodeState node, ISystemContext context, List<NodeState> nodes)
{
nodes.Add(node);
var children = new List<BaseInstanceState>();
node.GetChildren(context, children);
foreach (BaseInstanceState child in children)
{
CollectNodes(child, context, nodes);
}
}
private static RegistryState RegistryOf(XRegistryRegistrationNodeManager manager)
{
return (RegistryState)manager.Find(new NodeId(
XRegistryWellKnown.RegistryObject,
manager.SystemContext.NamespaceUris.GetIndexOrAppend(
XRegistryWellKnown.XRegistryNamespaceUri)))!;
}
private static Mock<IXRegistryResourceStore> CreateStore(InMemoryResourceStore contents)
{
var store = new Mock<IXRegistryResourceStore>(MockBehavior.Strict);
store.Setup(s => s.ReadAsync(
It.IsAny<string>(), It.IsAny<long>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
.Returns((string key, long offset, int count, CancellationToken ct) =>
contents.ReadAsync(key, offset, count, ct));
store.Setup(s => s.WriteAsync(
It.IsAny<string>(), It.IsAny<long>(), It.IsAny<ByteString>(), It.IsAny<CancellationToken>()))
.Returns((string key, long offset, ByteString data, CancellationToken ct) =>
contents.WriteAsync(key, offset, data, ct));
store.Setup(s => s.DeleteAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.Returns((string key, CancellationToken ct) => contents.DeleteAsync(key, ct));
store.Setup(s => s.GetLengthAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.Returns((string key, CancellationToken ct) => contents.GetLengthAsync(key, ct));
return store;
}
private static AsyncCustomNodeManager CreateManager(
string managerKind,
XRegistryServerOptions options)
{
IServerInternal server = XRegistryServerTestHarness
.CreateServer(options.RegistryNamespaceUri).Object;
return managerKind switch
{
"registration" => new XRegistryRegistrationNodeManager(server, null!, options),
"fast-path" => new XRegistryFastPathNodeManager(server, null!, options),
"federation" => new XRegistryFederationNodeManager(server, null!, options),
_ => throw new ArgumentOutOfRangeException(nameof(managerKind))
};
}
private static TManager CreateInterceptedManager<TManager>(
IServerInternal server,
XRegistryServerOptions options,
Func<NodeState, CancellationToken, ValueTask<NodeState>> beforeAdd)
where TManager : AsyncCustomNodeManager
{
var manager = new Mock<TManager>(server, null!, options) { CallBase = true };
manager.Protected().Setup<ValueTask<NodeState>>(
"AddBehaviourToPredefinedNodeAsync",
ItExpr.IsAny<ISystemContext>(),
ItExpr.IsAny<NodeState>(),
ItExpr.IsAny<CancellationToken>())
.Returns((ISystemContext _, NodeState node, CancellationToken ct) => beforeAdd(node, ct));
return manager.Object;
}
private sealed class DelayedRegistrationNodeManager(
IServerInternal server,
XRegistryServerOptions options) : XRegistryRegistrationNodeManager(server, null!, options)
{
public TaskCompletionSource<bool> LoadEntered { get; } =
new(TaskCreationOptions.RunContinuationsAsynchronously);
public TaskCompletionSource<bool> ReleaseLoad { get; } =
new(TaskCreationOptions.RunContinuationsAsynchronously);
public TaskCompletionSource<bool> GroupEntered { get; } =
new(TaskCreationOptions.RunContinuationsAsynchronously);
public TaskCompletionSource<bool> ReleaseGroup { get; } =
new(TaskCreationOptions.RunContinuationsAsynchronously);
protected override async ValueTask<NodeStateCollection> LoadPredefinedNodesAsync(
ISystemContext context,
CancellationToken cancellationToken = default)
{
LoadEntered.TrySetResult(true);
await ReleaseLoad.Task.WaitAsync(TimeSpan.FromSeconds(10), cancellationToken).ConfigureAwait(false);
return await base.LoadPredefinedNodesAsync(context, cancellationToken).ConfigureAwait(false);
}
protected override async ValueTask<NodeState> AddBehaviourToPredefinedNodeAsync(
ISystemContext context,
NodeState predefinedNode,
CancellationToken cancellationToken = default)
{
if (predefinedNode is GroupState group && group.GroupId?.Value == "held")
{
GroupEntered.TrySetResult(true);
await ReleaseGroup.Task.WaitAsync(TimeSpan.FromSeconds(10), cancellationToken)
.ConfigureAwait(false);
}
return await base.AddBehaviourToPredefinedNodeAsync(context, predefinedNode, cancellationToken)
.ConfigureAwait(false);
}
}
}
}