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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user