diff --git a/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/LazyBrowseNodeTests.cs b/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/LazyBrowseNodeTests.cs
index 96060b9..3a40275 100644
--- a/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/LazyBrowseNodeTests.cs
+++ b/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/LazyBrowseNodeTests.cs
@@ -142,6 +142,37 @@ public sealed class LazyBrowseNodeTests
Assert.Equal("7:abc:2", transport.BrowseChildrenCalls[2].Request.PageToken);
}
+ ///
+ /// Verifies that ten concurrent ExpandAsync calls issue exactly one RPC, not ten.
+ ///
+ [Fact]
+ public async Task Expand_CalledConcurrently_OnlyFiresOneRpc()
+ {
+ FakeGalaxyRepositoryTransport transport = CreateTransport();
+ transport.BrowseChildrenReplies.Enqueue(BuildReply(
+ children: [BuildObject(1, "Plant", isArea: true)],
+ childHasChildren: [true],
+ cacheSequence: 7));
+ transport.BrowseChildrenReplies.Enqueue(BuildReply(
+ children: [BuildObject(2, "Mixer_001")],
+ childHasChildren: [false],
+ cacheSequence: 7));
+
+ await using GalaxyRepositoryClient client = CreateClient(transport);
+ IReadOnlyList roots = await client.BrowseAsync();
+
+ // Fire ten concurrent expands of the same node.
+ Task[] tasks = Enumerable.Range(0, 10)
+ .Select(_ => roots[0].ExpandAsync())
+ .ToArray();
+ await Task.WhenAll(tasks);
+
+ Assert.True(roots[0].IsExpanded);
+ Assert.Single(roots[0].Children);
+ // 1 roots fetch + exactly 1 expand fetch = 2 total
+ Assert.Equal(2, transport.BrowseChildrenCalls.Count);
+ }
+
///
/// Verifies that BrowseChildrenOptions filter fields are forwarded to the BrowseChildren request.
///
diff --git a/clients/dotnet/ZB.MOM.WW.MxGateway.Client/LazyBrowseNode.cs b/clients/dotnet/ZB.MOM.WW.MxGateway.Client/LazyBrowseNode.cs
index 35e3bb9..360f4f5 100644
--- a/clients/dotnet/ZB.MOM.WW.MxGateway.Client/LazyBrowseNode.cs
+++ b/clients/dotnet/ZB.MOM.WW.MxGateway.Client/LazyBrowseNode.cs
@@ -13,6 +13,7 @@ public sealed class LazyBrowseNode
private readonly GalaxyRepositoryClient _client;
private readonly BrowseChildrenOptions _options;
private readonly List _children = [];
+ private readonly SemaphoreSlim _expandLock = new(1, 1);
private bool _isExpanded;
internal LazyBrowseNode(
@@ -43,6 +44,10 @@ public sealed class LazyBrowseNode
/// Fetches direct children from the gateway and populates .
/// Idempotent: subsequent calls are no-ops.
///
+ ///
+ /// Thread-safe: concurrent callers see exactly one fetch; subsequent callers
+ /// (after the first completes) return immediately.
+ ///
/// Token to observe for cancellation.
public async Task ExpandAsync(CancellationToken cancellationToken = default)
{
@@ -51,33 +56,46 @@ public sealed class LazyBrowseNode
return;
}
- string pageToken = string.Empty;
- HashSet seenPageTokens = new(StringComparer.Ordinal);
- do
+ await _expandLock.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
{
- BrowseChildrenRequest request = GalaxyRepositoryClient.BuildBrowseChildrenRequest(_options);
- request.ParentGobjectId = Object.GobjectId;
- request.PageToken = pageToken;
-
- BrowseChildrenReply reply = await _client
- .BrowseChildrenRawAsync(request, cancellationToken)
- .ConfigureAwait(false);
-
- for (int i = 0; i < reply.Children.Count; i++)
+ if (_isExpanded)
{
- bool hint = i < reply.ChildHasChildren.Count && reply.ChildHasChildren[i];
- _children.Add(new LazyBrowseNode(_client, reply.Children[i], hint, _options));
+ return;
}
- pageToken = reply.NextPageToken;
- if (!string.IsNullOrWhiteSpace(pageToken) && !seenPageTokens.Add(pageToken))
+ string pageToken = string.Empty;
+ HashSet seenPageTokens = new(StringComparer.Ordinal);
+ do
{
- throw new MxGatewayException(
- $"Galaxy BrowseChildren returned a repeated page token '{pageToken}'.");
+ BrowseChildrenRequest request = GalaxyRepositoryClient.BuildBrowseChildrenRequest(_options);
+ request.ParentGobjectId = Object.GobjectId;
+ request.PageToken = pageToken;
+
+ BrowseChildrenReply reply = await _client
+ .BrowseChildrenRawAsync(request, cancellationToken)
+ .ConfigureAwait(false);
+
+ for (int i = 0; i < reply.Children.Count; i++)
+ {
+ bool hint = i < reply.ChildHasChildren.Count && reply.ChildHasChildren[i];
+ _children.Add(new LazyBrowseNode(_client, reply.Children[i], hint, _options));
+ }
+
+ pageToken = reply.NextPageToken;
+ if (!string.IsNullOrWhiteSpace(pageToken) && !seenPageTokens.Add(pageToken))
+ {
+ throw new MxGatewayException(
+ $"Galaxy BrowseChildren returned a repeated page token '{pageToken}'.");
+ }
}
+ while (!string.IsNullOrWhiteSpace(pageToken));
+
+ _isExpanded = true;
+ }
+ finally
+ {
+ _expandLock.Release();
}
- while (!string.IsNullOrWhiteSpace(pageToken));
-
- _isExpanded = true;
}
}