fix(dcl): advance the session generation when a reconnect attempt is issued, not when its result is applied (review 2026-10-21 02/C1 follow-up)
This commit is contained in:
+81
-2
@@ -187,6 +187,58 @@ public class DataConnectionSameAdapterReconnectTests : TestKit
|
||||
Assert.DoesNotContain("live-2", adapter.ReleasedIds);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The connect-result/disconnect race (adversarial review of the C1 fix). The (re)connect
|
||||
/// succeeds and its <c>ConnectResult</c> is enqueued; before the actor processes it the new
|
||||
/// session dies, so the adapter's once-only <c>Disconnected</c> lands in the mailbox AFTER the
|
||||
/// result. That disconnect belongs to the session the result describes and must drive the
|
||||
/// actor back to Reconnecting — if it were stamped with a generation the result's processing
|
||||
/// then advanced past, it would be dropped as stale and the actor would sit Connected on a
|
||||
/// dead session that never raises Disconnected again.
|
||||
/// </summary>
|
||||
/// <param name="reconnect">False: the initial connect; true: a same-adapter reconnect.</param>
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void DisconnectQueuedBehindASuccessfulConnectResult_IsNotLost(bool reconnect)
|
||||
{
|
||||
var name = reconnect ? "race-reconnect" : "race-initial";
|
||||
var adapter = new GatedAdapter();
|
||||
var connectGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
adapter.NextConnectGate = connectGate.Task;
|
||||
var actor = CreateConnectionActor(name, adapter);
|
||||
var expectedConnectCalls = 1;
|
||||
|
||||
if (reconnect)
|
||||
{
|
||||
connectGate.SetResult();
|
||||
AwaitConnectedCount(name, 1);
|
||||
connectGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
adapter.NextConnectGate = connectGate.Task;
|
||||
adapter.RaiseDisconnected();
|
||||
expectedConnectCalls = 2;
|
||||
}
|
||||
|
||||
AwaitCondition(() => Volatile.Read(ref adapter.ConnectCalls) == expectedConnectCalls, TimeSpan.FromSeconds(5));
|
||||
var disconnectsBefore = HealthTransitions(name, ConnectionHealth.Disconnected);
|
||||
|
||||
// Hold the actor inside a health-report read …
|
||||
using var hold = new ManualResetEventSlim(false);
|
||||
adapter.HoldStatus = hold;
|
||||
actor.Tell(new DataConnectionActor.GetHealthReport());
|
||||
Assert.True(adapter.StatusEntered.Wait(TimeSpan.FromSeconds(5)));
|
||||
|
||||
// … while the connect succeeds (its result queues behind the held message) …
|
||||
connectGate.SetResult();
|
||||
Thread.Sleep(300);
|
||||
// … and the new session dies at once, queuing its disconnect behind the result.
|
||||
adapter.RaiseDisconnected();
|
||||
hold.Set();
|
||||
|
||||
AwaitCondition(() => HealthTransitions(name, ConnectionHealth.Disconnected) > disconnectsBefore,
|
||||
TimeSpan.FromSeconds(5), TimeSpan.FromMilliseconds(20));
|
||||
}
|
||||
|
||||
/// <summary>One gated <c>SubscribeBatchAsync</c> call.</summary>
|
||||
internal sealed class PendingBatch
|
||||
{
|
||||
@@ -219,7 +271,33 @@ public class DataConnectionSameAdapterReconnectTests : TestKit
|
||||
|
||||
public readonly ConcurrentQueue<string> ReleasedIds = new();
|
||||
|
||||
public ConnectionHealth Status { get; private set; } = ConnectionHealth.Disconnected;
|
||||
private ConnectionHealth _status = ConnectionHealth.Disconnected;
|
||||
|
||||
/// <summary>When set, the NEXT read of <see cref="Status"/> signals <see cref="StatusEntered"/> and blocks on it — a latch that holds the actor mid-message.</summary>
|
||||
public ManualResetEventSlim? HoldStatus;
|
||||
|
||||
/// <summary>Set once a held <see cref="Status"/> read has started.</summary>
|
||||
public readonly ManualResetEventSlim StatusEntered = new(false);
|
||||
|
||||
/// <summary>When set, the next <see cref="ConnectAsync"/> returns this task instead of completing at once.</summary>
|
||||
public Task? NextConnectGate;
|
||||
|
||||
/// <summary>Number of <see cref="ConnectAsync"/> calls so far.</summary>
|
||||
public int ConnectCalls;
|
||||
|
||||
public ConnectionHealth Status
|
||||
{
|
||||
get
|
||||
{
|
||||
if (Interlocked.Exchange(ref HoldStatus, null) is { } hold)
|
||||
{
|
||||
StatusEntered.Set();
|
||||
hold.Wait(TimeSpan.FromSeconds(10));
|
||||
}
|
||||
return _status;
|
||||
}
|
||||
private set => _status = value;
|
||||
}
|
||||
|
||||
public event Action? Disconnected;
|
||||
|
||||
@@ -244,8 +322,9 @@ public class DataConnectionSameAdapterReconnectTests : TestKit
|
||||
|
||||
public Task ConnectAsync(IDictionary<string, string> connectionDetails, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Interlocked.Increment(ref ConnectCalls);
|
||||
Status = ConnectionHealth.Connected;
|
||||
return System.Threading.Tasks.Task.CompletedTask;
|
||||
return Interlocked.Exchange(ref NextConnectGate, null) ?? System.Threading.Tasks.Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task DisconnectAsync(CancellationToken cancellationToken = default) =>
|
||||
|
||||
Reference in New Issue
Block a user