review(Client.Shared): fix Disconnect/failover subscription race + CT forwarding

Re-review at 7286d320. -012 (Medium): DisconnectAsync now snapshots+nulls the data/alarm
subscriptions under _subscriptionLock before async teardown (was racing RunFailoverAsync).
-013: SubscribeAlarmsAsync guarded by a semaphore (idempotent under concurrency). -014/-015:
forward CancellationToken through Delete/BrowseNext adapters. + TDD.
This commit is contained in:
Joseph Doherty
2026-06-19 11:58:15 -04:00
parent 887a31e825
commit d68c9db9f9
6 changed files with 240 additions and 28 deletions
@@ -101,10 +101,17 @@ internal sealed class FakeSubscriptionAdapter : ISubscriptionAdapter
return Task.CompletedTask;
}
/// <summary>
/// Gets the cancellation token that was supplied to the most recent <see cref="DeleteAsync"/> call,
/// so tests can assert the CT from the caller is honoured (Client.Shared-014).
/// </summary>
public CancellationToken? LastDeleteCt { get; private set; }
/// <inheritdoc />
public Task DeleteAsync(CancellationToken ct)
{
Deleted = true;
LastDeleteCt = ct;
lock (_itemsLock) _items.Clear();
return Task.CompletedTask;
}
@@ -1630,4 +1630,100 @@ public class OpcUaClientServiceTests : IDisposable
service.ShouldBeAssignableTo<IOpcUaClientService>();
service.Dispose();
}
// --- Client.Shared-012: DisconnectAsync subscription snapshot under lock ---
/// <summary>
/// Regression for Client.Shared-012: DisconnectAsync must snapshot the subscription
/// references under _subscriptionLock before teardown so a concurrent RunFailoverAsync
/// nulling the same fields cannot produce a NullReferenceException between the null-check
/// and the DeleteAsync call. This test verifies that when data and alarm subscriptions
/// exist at disconnect time the adapters are deleted exactly once and the supplied
/// CancellationToken is forwarded (Client.Shared-014).
/// </summary>
[Fact]
public async Task DisconnectAsync_WithActiveSubscriptions_DeletesBothAndForwardsCt()
{
var dataFakeSub = new FakeSubscriptionAdapter();
var alarmFakeSub = new FakeSubscriptionAdapter();
// First CreateSubscription call → data subscription; second → alarm subscription.
var callIndex = 0;
var session = new FakeSessionAdapter
{
NextSubscription = dataFakeSub
};
// Second CreateSubscription call should return the alarm fake.
// We wire it by overriding the factory queue after the first subscribe.
_sessionFactory.EnqueueSession(session);
await _service.ConnectAsync(ValidSettings());
// Subscribe data (uses dataFakeSub from session.NextSubscription).
await _service.SubscribeAsync(new NodeId("ns=2;s=Node1"));
// Now set NextSubscription on the session so SubscribeAlarmsAsync gets the alarm fake.
session.NextSubscription = alarmFakeSub;
await _service.SubscribeAlarmsAsync();
// Both subscriptions are now active. Disconnect with a cancellable token.
using var cts = new CancellationTokenSource();
await _service.DisconnectAsync(cts.Token);
// Both adapters must have been deleted.
dataFakeSub.Deleted.ShouldBeTrue();
alarmFakeSub.Deleted.ShouldBeTrue();
// The CancellationToken must have been forwarded (Client.Shared-014).
dataFakeSub.LastDeleteCt.ShouldBe(cts.Token);
alarmFakeSub.LastDeleteCt.ShouldBe(cts.Token);
}
// --- Client.Shared-013: SubscribeAlarmsAsync idempotency under lock ---
/// <summary>
/// Regression for Client.Shared-013: the _alarmSubscription != null guard in
/// SubscribeAlarmsAsync must be checked under _subscriptionLock so concurrent callers
/// cannot both pass the null-check and create duplicate alarm subscriptions. Exercises
/// concurrent calls from 20 tasks and verifies only one event subscription is created.
/// </summary>
[Fact]
public async Task SubscribeAlarmsAsync_ConcurrentDuplicateCalls_CreatesExactlyOneSubscription()
{
var fakeSub = new FakeSubscriptionAdapter();
var session = new FakeSessionAdapter { NextSubscription = fakeSub };
_sessionFactory.EnqueueSession(session);
await _service.ConnectAsync(ValidSettings());
// Fire many concurrent SubscribeAlarmsAsync calls — only one should "win".
var tasks = Enumerable.Range(0, 20).Select(_ =>
Task.Run(() => _service.SubscribeAlarmsAsync()));
await Task.WhenAll(tasks);
// Exactly one event monitored item must have been added across all calls.
var totalEventItems = session.CreatedSubscriptions.Sum(s => s.AddEventCount);
totalEventItems.ShouldBe(1);
}
// --- Client.Shared-014: CancellationToken forwarding ---
/// <summary>
/// Regression for Client.Shared-014: UnsubscribeAsync must forward the caller's
/// CancellationToken to the subscription adapter's DeleteAsync so the operation
/// can be cancelled — the pre-fix code passed CancellationToken.None.
/// </summary>
[Fact]
public async Task UnsubscribeAlarmsAsync_ForwardsCancellationToken()
{
var fakeSub = new FakeSubscriptionAdapter();
var session = new FakeSessionAdapter { NextSubscription = fakeSub };
_sessionFactory.EnqueueSession(session);
await _service.ConnectAsync(ValidSettings());
await _service.SubscribeAlarmsAsync();
using var cts = new CancellationTokenSource();
await _service.UnsubscribeAlarmsAsync(cts.Token);
fakeSub.LastDeleteCt.ShouldBe(cts.Token);
}
}