fix(dcl): capture adapter into locals before background tasks — no off-thread reads of the mutable _adapter field (S7)
This commit is contained in:
@@ -463,6 +463,77 @@ public class DataConnectionActorTests : TestKit
|
||||
"sensor/temp", Arg.Any<SubscriptionCallback>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
// ── PLAN-03 Task 16 (S7): background tasks must use the adapter that started them ──
|
||||
|
||||
[Fact]
|
||||
public async Task Task16_InFlightSubscribe_ContinuesOnOriginalAdapter_AfterFailover()
|
||||
{
|
||||
// Regression for S7. HandleSubscribe launches a background Task.Run that iterates
|
||||
// the tags and awaits _adapter.SubscribeAsync per tag. If a failover swaps the
|
||||
// mutable _adapter field mid-loop, later iterations must still hit the adapter that
|
||||
// STARTED the subscribe — not the freshly-swapped-in backup. Pre-fix the loop re-read
|
||||
// the _adapter field after the first await, so a tag after the first was subscribed
|
||||
// against the wrong (backup) adapter generation.
|
||||
var primaryConfig = new Dictionary<string, string> { ["Endpoint"] = "opc.tcp://primary:4840" };
|
||||
var backupConfig = new Dictionary<string, string> { ["Endpoint"] = "opc.tcp://backup:4840" };
|
||||
var primaryAdapter = Substitute.For<IDataConnection>();
|
||||
var backupAdapter = Substitute.For<IDataConnection>();
|
||||
|
||||
// Primary connects once, then every reconnect fails so the disconnect drives failover.
|
||||
var primaryConnectCount = 0;
|
||||
primaryAdapter.ConnectAsync(Arg.Any<IDictionary<string, string>>(), Arg.Any<CancellationToken>())
|
||||
.Returns(_ => Interlocked.Increment(ref primaryConnectCount) == 1
|
||||
? Task.CompletedTask
|
||||
: Task.FromException(new Exception("Primary down")));
|
||||
|
||||
// The first tag's subscribe blocks on the gate (in-flight across the failover);
|
||||
// the second tag resolves immediately once the loop resumes.
|
||||
var subscribeGate = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
primaryAdapter.SubscribeAsync(Arg.Any<string>(), Arg.Any<SubscriptionCallback>(), Arg.Any<CancellationToken>())
|
||||
.Returns(_ => subscribeGate.Task, _ => Task.FromResult("sub-primary-2"));
|
||||
primaryAdapter.ReadAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new ReadResult(false, null, null));
|
||||
|
||||
_mockFactory.Create("OpcUa", Arg.Is<IDictionary<string, string>>(d => d["Endpoint"] == "opc.tcp://backup:4840"))
|
||||
.Returns(backupAdapter);
|
||||
backupAdapter.ConnectAsync(Arg.Any<IDictionary<string, string>>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.CompletedTask);
|
||||
backupAdapter.SubscribeAsync(Arg.Any<string>(), Arg.Any<SubscriptionCallback>(), Arg.Any<CancellationToken>())
|
||||
.Returns("sub-backup");
|
||||
backupAdapter.ReadAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new ReadResult(false, null, null));
|
||||
|
||||
var actor = CreateFailoverActor(primaryAdapter, "task16-inflight", primaryConfig, backupConfig, failoverRetryCount: 1);
|
||||
|
||||
// Reach Connected on primary.
|
||||
AwaitCondition(() => primaryConnectCount >= 1, TimeSpan.FromSeconds(2));
|
||||
await Task.Delay(200);
|
||||
|
||||
// Kick off a two-tag subscribe; the first tag blocks in-flight on the gate.
|
||||
actor.Tell(new SubscribeTagsRequest("c1", "inst1", "task16-inflight", ["t1", "t2"], DateTimeOffset.UtcNow));
|
||||
|
||||
// The background loop must have called the primary's SubscribeAsync (t1) and parked.
|
||||
AwaitCondition(() =>
|
||||
primaryAdapter.ReceivedCalls().Count(c => c.GetMethodInfo().Name == "SubscribeAsync") >= 1,
|
||||
TimeSpan.FromSeconds(3));
|
||||
|
||||
// Fail over while the subscribe is in flight: swaps _adapter to backup, bumps generation.
|
||||
RaiseDisconnected(primaryAdapter);
|
||||
AwaitCondition(() =>
|
||||
backupAdapter.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "ConnectAsync"),
|
||||
TimeSpan.FromSeconds(5));
|
||||
|
||||
// Release the in-flight subscribe; the loop resumes and subscribes t2.
|
||||
subscribeGate.SetResult("sub-primary-1");
|
||||
|
||||
// t2 must go to the ORIGINAL (primary) adapter, never the swapped-in backup.
|
||||
AwaitCondition(() =>
|
||||
primaryAdapter.ReceivedCalls().Count(c => c.GetMethodInfo().Name == "SubscribeAsync") >= 2,
|
||||
TimeSpan.FromSeconds(5));
|
||||
Assert.DoesNotContain(backupAdapter.ReceivedCalls(),
|
||||
c => c.GetMethodInfo().Name == "SubscribeAsync");
|
||||
}
|
||||
|
||||
// ── DataConnectionLayer-001: subscribe must not mutate actor state off-thread ──
|
||||
|
||||
private static async Task<string> DelayedSubscribeAsync()
|
||||
|
||||
Reference in New Issue
Block a user