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
@@ -269,10 +269,12 @@ public class DataConnectionActor : UntypedActor, IWithTimers
/// </para>
///
/// <para>
/// Residual (accepted, and in the safe direction): a disconnect raised by the PREVIOUS
/// session but delivered after the next attempt has already cleared the flag is attributed to
/// the new attempt, costing one extra reconnect cycle. Being wedged Connected on a dead
/// adapter is the failure this trades against.
/// Each attempt advances the session generation before it is issued, so a disconnect the
/// PREVIOUS session already raised (stamped with its generation) is dropped as stale rather
/// than attributed to the new attempt. Residual (accepted, and in the safe direction): a
/// previous-session disconnect raised only AFTER the next attempt re-hooked the handler is
/// attributed to the new attempt, costing one extra reconnect cycle. Being wedged Connected
/// on a dead adapter is the failure this trades against.
/// </para>
/// </summary>
private bool _disconnectDuringConnectAttempt;
@@ -408,7 +410,7 @@ public class DataConnectionActor : UntypedActor, IWithTimers
switch (message)
{
case AttemptConnect:
HandleAttemptConnect();
HandleAttemptConnect(advanceSession: false);
break;
case ConnectResult result:
HandleConnectResult(result);
@@ -736,6 +738,9 @@ public class DataConnectionActor : UntypedActor, IWithTimers
/// <summary>
/// Advances <see cref="_adapterGeneration"/> for a new SESSION on the SAME adapter — a plain
/// drop + reconnect, where <see cref="SwapAdapter"/> never runs (review 2026-10-21 02/C1).
/// Called by <see cref="HandleAttemptConnect"/> before each RECONNECT attempt is issued, so the
/// attempt's result, the new session's <c>Disconnected</c> and everything started after it
/// share one generation, and anything started against an earlier session is stale.
///
/// <para>
/// Without it, an initial subscribe issued against the dead session (in flight for up to
@@ -751,9 +756,9 @@ public class DataConnectionActor : UntypedActor, IWithTimers
/// <para>
/// The <c>Disconnected</c> handler stamps the generation it was hooked with, so it is
/// re-hooked for the new generation. The new handler is hooked BEFORE the old one is
/// removed: a disconnect raised in between is then delivered twice (old stamp dropped as
/// stale, new stamp acted on) rather than lost, which would wedge the actor Connected on a
/// dead session.
/// removed, so no window exists with no handler at all. Because this runs before the
/// attempt's <c>ConnectAsync</c>, the only disconnects that can carry the OLD stamp belong
/// to the previous (already-dead) session and are correctly dropped as stale.
/// </para>
/// </summary>
private void AdvanceSessionGeneration()
@@ -852,7 +857,7 @@ public class DataConnectionActor : UntypedActor, IWithTimers
switch (message)
{
case AttemptConnect:
HandleAttemptConnect();
HandleAttemptConnect(advanceSession: true);
break;
case ConnectResult result:
HandleReconnectResult(result);
@@ -944,13 +949,27 @@ public class DataConnectionActor : UntypedActor, IWithTimers
// ── Connection Management ──
private void HandleAttemptConnect()
/// <param name="advanceSession">
/// True from Reconnecting: a session has existed on this adapter, so the attempt opens a NEW
/// one and gets its own generation (see <see cref="AdvanceSessionGeneration"/>). False from
/// the initial Connecting state, where no session has ever been established and nothing is
/// in flight against one, so the generation the handler and result share is already right.
/// </param>
private void HandleAttemptConnect(bool advanceSession)
{
_log.Debug("[{0}] Attempting connection...", _connectionName);
// Scope the pending-disconnect flag to THIS attempt. Anything noted before now
// belongs to a session that is already over and must not be replayed against the one
// about to start.
_disconnectDuringConnectAttempt = false;
// Every reconnect attempt opens a NEW session, so it gets its own generation (review 2026-10-21
// 02/C1), advanced — and the Disconnected handler re-hooked — BEFORE ConnectAsync is
// issued. The new session's Disconnected therefore always carries the same generation
// as its ConnectResult: a disconnect that lands in the mailbox right behind a successful
// result is acted on, not dropped as stale (which would wedge the actor Connected on a
// dead session — the adapter raises Disconnected only once per session).
if (advanceSession)
AdvanceSessionGeneration();
var self = Self;
// Stamp the generation this attempt belongs to, exactly as every other piped
// completion does. A failover or an in-place reconfigure landing while ConnectAsync
@@ -1146,12 +1165,9 @@ public class DataConnectionActor : UntypedActor, IWithTimers
$"Connection restored on {_activeEndpoint} endpoint", null);
}
// The adapter object survived the drop but its SESSION did not. Advance the
// generation BEFORE re-subscribing so every completion, value and alarm callback
// started against the dead session takes the existing stale path (review
// 2026-10-21 02/C1) — exactly as it would after a swap.
AdvanceSessionGeneration();
// The session generation was already advanced when this attempt was issued
// (HandleAttemptConnect), so ReSubscribeAll below runs on the NEW session's
// generation and every completion from the dead one takes the stale path.
// Transparent re-subscribe — re-establish all active subscriptions
ReSubscribeAll();
// Re-establish native alarm feeds (source replays a snapshot).