client/dotnet: make LazyBrowseNode.ExpandAsync thread-safe

This commit is contained in:
Joseph Doherty
2026-05-28 14:28:36 -04:00
parent b4bc2df015
commit eacfeff9fb
2 changed files with 70 additions and 21 deletions
@@ -142,6 +142,37 @@ public sealed class LazyBrowseNodeTests
Assert.Equal("7:abc:2", transport.BrowseChildrenCalls[2].Request.PageToken); Assert.Equal("7:abc:2", transport.BrowseChildrenCalls[2].Request.PageToken);
} }
/// <summary>
/// Verifies that ten concurrent ExpandAsync calls issue exactly one RPC, not ten.
/// </summary>
[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<LazyBrowseNode> 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);
}
/// <summary> /// <summary>
/// Verifies that BrowseChildrenOptions filter fields are forwarded to the BrowseChildren request. /// Verifies that BrowseChildrenOptions filter fields are forwarded to the BrowseChildren request.
/// </summary> /// </summary>
@@ -13,6 +13,7 @@ public sealed class LazyBrowseNode
private readonly GalaxyRepositoryClient _client; private readonly GalaxyRepositoryClient _client;
private readonly BrowseChildrenOptions _options; private readonly BrowseChildrenOptions _options;
private readonly List<LazyBrowseNode> _children = []; private readonly List<LazyBrowseNode> _children = [];
private readonly SemaphoreSlim _expandLock = new(1, 1);
private bool _isExpanded; private bool _isExpanded;
internal LazyBrowseNode( internal LazyBrowseNode(
@@ -43,6 +44,10 @@ public sealed class LazyBrowseNode
/// Fetches direct children from the gateway and populates <see cref="Children"/>. /// Fetches direct children from the gateway and populates <see cref="Children"/>.
/// Idempotent: subsequent calls are no-ops. /// Idempotent: subsequent calls are no-ops.
/// </summary> /// </summary>
/// <remarks>
/// Thread-safe: concurrent callers see exactly one fetch; subsequent callers
/// (after the first completes) return immediately.
/// </remarks>
/// <param name="cancellationToken">Token to observe for cancellation.</param> /// <param name="cancellationToken">Token to observe for cancellation.</param>
public async Task ExpandAsync(CancellationToken cancellationToken = default) public async Task ExpandAsync(CancellationToken cancellationToken = default)
{ {
@@ -51,6 +56,14 @@ public sealed class LazyBrowseNode
return; return;
} }
await _expandLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (_isExpanded)
{
return;
}
string pageToken = string.Empty; string pageToken = string.Empty;
HashSet<string> seenPageTokens = new(StringComparer.Ordinal); HashSet<string> seenPageTokens = new(StringComparer.Ordinal);
do do
@@ -80,4 +93,9 @@ public sealed class LazyBrowseNode
_isExpanded = true; _isExpanded = true;
} }
finally
{
_expandLock.Release();
}
}
} }