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:
Joseph Doherty
2026-10-22 08:32:47 -04:00
parent d8d44e0b87
commit 394234b450
3 changed files with 114 additions and 19 deletions
@@ -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) =>