fix(dcl): advance the session generation on same-adapter reconnect and session-scope MxGateway subscription ids (review 2026-10-21 02/C1, 02/L1)

This commit is contained in:
Joseph Doherty
2026-10-22 06:23:06 -04:00
parent b811a2451b
commit 8c1bd2c3fc
7 changed files with 584 additions and 15 deletions
@@ -710,6 +710,38 @@ public class DataConnectionActor : UntypedActor, IWithTimers
HookAdapterDisconnected();
}
/// <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).
///
/// <para>
/// Without it, an initial subscribe issued against the dead session (in flight for up to
/// <see cref="DataConnectionOptions.SeedOverallTimeout"/>) carried the current generation and
/// was applied against the new session: landing before the re-subscribe row for its tag it
/// parked a dead handle under the tag's key, so the LIVE row looked redundant and was
/// released (the tag reads resolved and is silent until the next reconnect); landing after,
/// its dead handle was released against the adapter whose new session may have reused that
/// handle number for another tag (MxGateway); and a connection-level failure row in it
/// knocked the freshly reconnected link back into Reconnecting.
/// </para>
///
/// <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.
/// </para>
/// </summary>
private void AdvanceSessionGeneration()
{
var previousHandler = _disconnectedHandler;
_adapterGeneration++;
HookAdapterDisconnected();
if (previousHandler is not null)
_adapter.Disconnected -= previousHandler;
}
/// <summary>
/// Applies a new configuration to this LIVE connection <b>in place</b> (deferred register
/// row 97) — the non-destructive alternative to stopping and re-creating the connection
@@ -1076,6 +1108,12 @@ 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();
// Transparent re-subscribe — re-establish all active subscriptions
ReSubscribeAll();
// Re-establish native alarm feeds (source replays a snapshot).
@@ -1405,8 +1443,19 @@ public class DataConnectionActor : UntypedActor, IWithTimers
/// The outgoing adapter is disposed fire-and-forget by <see cref="SwapAdapter"/> and may
/// already be gone, so a failure here is expected and logged at Debug rather than surfaced.
/// </summary>
private void ReleaseStaleAgainstOwnAdapter(string description, Func<Task> release)
private void ReleaseStaleAgainstOwnAdapter(object owner, string description, Func<Task> release)
{
// Same-adapter reconnect (review 2026-10-21 02/C1): the generation advanced because the
// SESSION changed, not the adapter. The dead session's handles died with it, and the
// adapter now speaks for the NEW session — where a session-scoped handle number (an
// MxGateway int) may already belong to a live item. Releasing would tear that down.
if (ReferenceEquals(owner, _adapter))
{
_log.Debug("[{0}] Not releasing {1}: it belongs to a session of the current adapter that "
+ "has since ended, so it died with that session", _connectionName, description);
return;
}
try
{
release().ContinueWith(
@@ -1618,6 +1667,7 @@ public class DataConnectionActor : UntypedActor, IWithTimers
// MxGateway's are plain ints that collide across sessions, so releasing by handle
// against the replacement adapter can tear down a live subscription.
ReleaseStaleAgainstOwnAdapter(
msg.Adapter,
$"{staleIds.Count} tag subscription handle(s) from adapter generation {msg.AdapterGeneration}",
() => UnsubscribeIdsAsync(msg.Adapter, staleIds));
@@ -3078,6 +3128,7 @@ public class DataConnectionActor : UntypedActor, IWithTimers
// one (see ReleaseStaleAgainstOwnAdapter).
if (msg is { Success: true, SubscriptionId: not null })
ReleaseStaleAgainstOwnAdapter(
msg.Adapter,
$"the alarm feed for {msg.SourceReference} from adapter generation {msg.AdapterGeneration}",
() => msg.Adapter.UnsubscribeAlarmsAsync(msg.SubscriptionId));