Skip to content

Commit c486c79

Browse files
committed
Artery: stop dropping the first ordinary message when the peer dialed first
Fixes #8496. `OutboundHandshakeStage.PreStart` treated "this association already has a `UniqueRemoteAddress`" as "our handshake is done" and skipped straight to `Completed`. That field is also set by the INBOUND direction - when WE handle the peer's `HandshakeReq` - so it proves we know the peer's uid, not that the peer knows ours. When a peer dialed us first, our first outbound ordinary stream therefore sent no `HandshakeReq` of its own, and our first user message raced our `HandshakeRsp` (a different TCP connection, no ordering relationship) into the peer's unknown-origin gate: Dropping inbound message [Akka.Actor.ActorSelectionMessage] from unknown origin uid [817823530] (no completed handshake for this uid yet). Ordinary messages have no resend path, so the message was gone. The receiving side documented an invariant ("the sender's gate cannot complete before we have processed its Req") that the `PreStart` shortcut quietly broke. `AssociationState.OutboundHandshakeCompleted` now records, per INCARNATION, whether the peer has answered a `HandshakeReq` of ours. Only the new `CompleteOutboundHandshake` sets it, and only `InboundHandshakeStage.HandleRsp` calls that - a Rsp is the sole event proving the peer registered our uid. The flag rides the same immutable snapshot and CAS swap as the rest of the association state: a uid change resets it, `Quarantine` clears it, so it never outlives the incarnation it describes. Two gates consult it, both for ordinary/large/lane streams only: * `PreStart`'s fast path (`CanSkipOwnHandshake`) - so a stream materialized against an inbound-only association runs the normal, well-tested handshake path instead of shortcutting. * `RefreshCompletionFromContext` - load-bearing too, since the generation counter it already checked is advanced by the peer's own inbound Req: without it, a stream that did send its Req would complete on the peer's Req *retry* landing while we wait for our Rsp, which is the same bug through the back door. The CONTROL stream deliberately keeps the old, weaker rule. Its traffic is never subject to the receiver's unknown-origin drop - every control envelope (handshake, heartbeat, quarantine notice, system messages and their Ack/Nack) is dispatched regardless of whether the origin uid is registered - and it must be able to complete on an inbound handshake: the `HandshakeRsp` we owe a peer is enqueued on that very control queue, so a strict rule there would deadlock two systems that dial each other at the same instant, each holding the Rsp the other is waiting for. `ForceReqOnStart` is unaffected: it still forces the full handshake path, and the two conditions compose - the fast path is skipped when either says so. Also, at both inbound gates (`InboundHandshakeStage` and the lane-routed copy in `ArteryInboundProcessingStage`), the unknown-origin drop is raised from DEBUG to a rate-limited WARNING: the first drop warns at once, later ones at most once per 10s carrying the count suppressed in between. This class of loss should be diagnosable in the field, not invisible. Tests. `ArteryPeerDialedFirstSpec` covers the issue's stage-level repro (an association created by a real inbound `HandshakeReq`; the ordinary stage must send its own Req and hold traffic until a real `HandshakeRsp` arrives), the peer's-Req-retry variant, fast-path preservation, the new warning, and an end-to-end test over two real Artery systems where B dials A first and A's `HandshakeRsp` is dropped - the deterministic form of the race. Four of the five fail on unfixed code; the fast-path test passes before and after, as it must. `AssociationStateSpec` gains the transition, incarnation-reset and quarantine coverage for the new flag. The invariant comment on `InboundHandshakeStage` now describes the corrected construction.
1 parent e6e014d commit c486c79

9 files changed

Lines changed: 614 additions & 35 deletions

src/core/Akka.Remote.Tests/Artery/ArteryHandshakeSpec.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ public async Task OutboundHandshakeStage_should_inject_req_first_hold_then_flow_
102102

103103
// Simulate what InboundHandshakeStage does when the peer's HandshakeRsp arrives on
104104
// OUR inbound pipeline for the return direction.
105-
registry.CompleteHandshake(remoteAddress, new UniqueAddress(remoteAddress, 222L));
105+
registry.CompleteOutboundHandshake(remoteAddress, new UniqueAddress(remoteAddress, 222L));
106106

107107
// Nothing re-triggers the stage directly; per the documented notification mechanism,
108108
// the retry timer (still running) is what notices completion and delivers the held
@@ -152,7 +152,7 @@ public async Task OutboundHandshakeStage_should_emit_control_envelope_ahead_of_h
152152
// slow to arrive.
153153
await sub.ExpectNoMsgAsync(TimeSpan.FromMilliseconds(300));
154154

155-
registry.CompleteHandshake(remoteAddress, new UniqueAddress(remoteAddress, 222L));
155+
registry.CompleteOutboundHandshake(remoteAddress, new UniqueAddress(remoteAddress, 222L));
156156

157157
// The retry timer (still running) is what notices completion and releases the held
158158
// element - bounded by one retry interval; a legal idempotent retry HandshakeReq may
@@ -268,7 +268,7 @@ public async Task OutboundHandshakeStage_non_control_should_route_req_via_send_c
268268
await pub.SendNextAsync(new OutboundEnvelope("user-message", null, null));
269269
await sub.ExpectNoMsgAsync(TimeSpan.FromMilliseconds(200));
270270

271-
registry.CompleteHandshake(remoteAddress, new UniqueAddress(remoteAddress, 555L));
271+
registry.CompleteOutboundHandshake(remoteAddress, new UniqueAddress(remoteAddress, 555L));
272272

273273
var delivered = await sub.ExpectNextAsync(TimeSpan.FromSeconds(3));
274274
delivered.Message.Should().Be("user-message");
@@ -285,7 +285,7 @@ public async Task OutboundHandshakeStage_non_control_should_not_hold_elements_fo
285285

286286
// Already-associated at PreStart (Completed immediately) -- exercises the
287287
// "ShouldReinjectForLiveness" path directly rather than the initial handshake path.
288-
registry.CompleteHandshake(remoteAddress, new UniqueAddress(remoteAddress, 777L));
288+
registry.CompleteOutboundHandshake(remoteAddress, new UniqueAddress(remoteAddress, 777L));
289289

290290
var stage = new OutboundHandshakeStage(
291291
context,

src/core/Akka.Remote.Tests/Artery/ArteryPeerDialedFirstSpec.cs

Lines changed: 327 additions & 0 deletions
Large diffs are not rendered by default.

src/core/Akka.Remote.Tests/Artery/AssociationStateSpec.cs

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,65 @@ public void Quarantine_should_ignore_stale_uid()
9494
newState.IsQuarantined(firstPeer.Uid).Should().BeFalse();
9595
}
9696

97+
[Fact(DisplayName = "Only CompleteOutboundHandshake should set OutboundHandshakeCompleted (issue #8496)")]
98+
public void Only_CompleteOutboundHandshake_should_set_outbound_completed()
99+
{
100+
var peer = new UniqueAddress(RemoteAddress, 1L);
101+
102+
var fromPeersReq = AssociationState.Create().CompleteHandshake(peer);
103+
fromPeersReq.UniqueRemoteAddress.Should().Be(peer, "the peer's own Req still registers its uid");
104+
fromPeersReq.OutboundHandshakeCompleted.Should().BeFalse("the peer has learned nothing about US from its own Req");
105+
106+
AssociationState.Create().CompleteOutboundHandshake(peer).OutboundHandshakeCompleted
107+
.Should().BeTrue("a Rsp is proof the peer registered our uid");
108+
}
109+
110+
[Fact(DisplayName = "CompleteOutboundHandshake should flip OutboundHandshakeCompleted after an inbound-only handshake, then be an idempotent no-op")]
111+
public void CompleteOutboundHandshake_should_flip_the_flag_then_be_idempotent()
112+
{
113+
var peer = new UniqueAddress(RemoteAddress, 1L);
114+
var inboundOnly = AssociationState.Create().CompleteHandshake(peer);
115+
116+
var answered = inboundOnly.CompleteOutboundHandshake(peer);
117+
118+
answered.Should().NotBeSameAs(inboundOnly, "the false -> true flip is a real transition, not a no-op");
119+
answered.Incarnation.Should().Be(inboundOnly.Incarnation, "the peer did not restart");
120+
answered.OutboundHandshakeCompleted.Should().BeTrue();
121+
122+
answered.CompleteOutboundHandshake(peer).Should().BeSameAs(answered, "re-answering is an idempotent no-op");
123+
answered.CompleteHandshake(peer).Should().BeSameAs(answered, "a later inbound Req must not clear what we already proved");
124+
}
125+
126+
[Fact(DisplayName = "A new incarnation (peer restart) should reset OutboundHandshakeCompleted")]
127+
public void New_incarnation_should_reset_outbound_completed()
128+
{
129+
var oldPeer = new UniqueAddress(RemoteAddress, 1L);
130+
var newPeer = new UniqueAddress(RemoteAddress, 2L);
131+
var answered = AssociationState.Create().CompleteOutboundHandshake(oldPeer);
132+
133+
var restarted = answered.CompleteHandshake(newPeer);
134+
135+
restarted.Incarnation.Should().Be(answered.Incarnation + 1);
136+
restarted.OutboundHandshakeCompleted.Should().BeFalse(
137+
"the RESTARTED peer has never answered a Req of ours -- what the previous incarnation knew about us died with it");
138+
139+
answered.CompleteOutboundHandshake(newPeer).OutboundHandshakeCompleted
140+
.Should().BeTrue("unless the very message that changed the uid was itself a Rsp");
141+
}
142+
143+
[Fact(DisplayName = "Quarantine should clear OutboundHandshakeCompleted")]
144+
public void Quarantine_should_clear_outbound_completed()
145+
{
146+
var peer = new UniqueAddress(RemoteAddress, 1L);
147+
var answered = AssociationState.Create().CompleteOutboundHandshake(peer);
148+
149+
var (quarantined, acted) = answered.Quarantine(peer.Uid);
150+
151+
acted.Should().BeTrue();
152+
quarantined.OutboundHandshakeCompleted.Should().BeFalse();
153+
quarantined.UniqueRemoteAddress.Should().Be(peer, "quarantine is uid-scoped -- it does not forget which uid it applies to");
154+
}
155+
97156
[Fact(DisplayName = "Quarantine should act on the current uid and mark it quarantined")]
98157
public void Quarantine_should_act_on_current_uid()
99158
{

src/core/Akka.Remote/Artery/ArteryInboundProcessingStage.cs

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -328,8 +328,17 @@ public LaneWorkItem(ReadOnlySequence<byte> payload, int serializerId, string man
328328

329329
private sealed class Logic : GraphStageLogic, IInHandler, IOutHandler
330330
{
331+
/// <summary>
332+
/// Rate limit for this connection's unknown-origin drop warnings -- the lane path's
333+
/// half of the pair <see cref="InboundHandshakeStage"/> owns the other half of; see
334+
/// that stage for the rationale.
335+
/// </summary>
336+
private static readonly TimeSpan UnknownOriginWarnInterval = TimeSpan.FromSeconds(10);
337+
331338
private readonly ArteryInboundProcessingStage _stage;
332339
private readonly Queue<IInboundEnvelope> _pending = new();
340+
private DateTime _lastUnknownOriginWarning = DateTime.MinValue;
341+
private long _suppressedUnknownOriginDrops;
333342

334343
private readonly byte[] _preambleBuffer = new byte[ArteryConnectionHeader.Length];
335344
private int _preambleFilled;
@@ -762,9 +771,22 @@ private bool ProcessFrameLaneMode(ReadOnlySequence<byte> frameBody)
762771
// user-message dispatch per connection" holds for the lane path too.
763772
if (!_stage.InboundContext!.IsKnownOrigin(decoded.Header.OriginUid))
764773
{
765-
Log.Debug(
766-
"Dropping inbound lane-routed Artery message from unknown origin uid [{0}] (no completed handshake for this uid yet).",
767-
decoded.Header.OriginUid);
774+
var now = DateTime.UtcNow;
775+
if (_lastUnknownOriginWarning == DateTime.MinValue ||
776+
now - _lastUnknownOriginWarning >= UnknownOriginWarnInterval)
777+
{
778+
Log.Warning(
779+
"Dropping inbound lane-routed Artery message from unknown origin uid [{0}]: no completed handshake for this uid yet. " +
780+
"The message is LOST - ordinary messages are not resent. [{1}] further drop(s) suppressed since the last warning.",
781+
decoded.Header.OriginUid, _suppressedUnknownOriginDrops);
782+
_lastUnknownOriginWarning = now;
783+
_suppressedUnknownOriginDrops = 0;
784+
}
785+
else
786+
{
787+
_suppressedUnknownOriginDrops++;
788+
}
789+
768790
return true;
769791
}
770792

src/core/Akka.Remote/Artery/AssociationRegistry.cs

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -996,14 +996,27 @@ public void DrainLargeToDropped(Action<IOutboundEnvelope> onDrained)
996996
/// snapshot, so <see cref="AssociationRegistry"/> can tell — without a separate,
997997
/// racy read — whether (and from what uid) an incarnation change just happened.
998998
/// </summary>
999-
public (AssociationState Previous, AssociationState Updated) CompleteHandshake(UniqueAddress peer)
999+
public (AssociationState Previous, AssociationState Updated) CompleteHandshake(UniqueAddress peer) =>
1000+
Transition(peer, static (state, p) => state.CompleteHandshake(p));
1001+
1002+
/// <summary>
1003+
/// As <see cref="CompleteHandshake"/>, but applying
1004+
/// <see cref="AssociationState.CompleteOutboundHandshake"/> -- the only path that sets
1005+
/// <see cref="AssociationState.OutboundHandshakeCompleted"/> (issue #8496).
1006+
/// </summary>
1007+
public (AssociationState Previous, AssociationState Updated) CompleteOutboundHandshake(UniqueAddress peer) =>
1008+
Transition(peer, static (state, p) => state.CompleteOutboundHandshake(p));
1009+
1010+
private (AssociationState Previous, AssociationState Updated) Transition(
1011+
UniqueAddress peer,
1012+
Func<AssociationState, UniqueAddress, AssociationState> transition)
10001013
{
10011014
Interlocked.Increment(ref _handshakeGeneration);
10021015

10031016
while (true)
10041017
{
10051018
var current = _state;
1006-
var updated = current.CompleteHandshake(peer);
1019+
var updated = transition(current, peer);
10071020

10081021
if (ReferenceEquals(updated, current))
10091022
return (current, current);
@@ -1137,7 +1150,27 @@ public Association AssociationFor(Address remoteAddress) =>
11371150
public AssociationState CompleteHandshake(Address remoteAddress, UniqueAddress peer)
11381151
{
11391152
var association = AssociationFor(remoteAddress);
1140-
var (previous, updated) = association.CompleteHandshake(peer);
1153+
return Complete(association, peer, association.CompleteHandshake(peer));
1154+
}
1155+
1156+
/// <summary>
1157+
/// As <see cref="CompleteHandshake"/>, but records that the peer answered a
1158+
/// <see cref="HandshakeReq"/> of OURS -- see
1159+
/// <see cref="AssociationState.OutboundHandshakeCompleted"/>. Called only from
1160+
/// <see cref="InboundHandshakeStage"/>'s <c>HandleRsp</c>.
1161+
/// </summary>
1162+
public AssociationState CompleteOutboundHandshake(Address remoteAddress, UniqueAddress peer)
1163+
{
1164+
var association = AssociationFor(remoteAddress);
1165+
return Complete(association, peer, association.CompleteOutboundHandshake(peer));
1166+
}
1167+
1168+
private AssociationState Complete(
1169+
Association association,
1170+
UniqueAddress peer,
1171+
(AssociationState Previous, AssociationState Updated) transition)
1172+
{
1173+
var (previous, updated) = transition;
11411174

11421175
if (!ReferenceEquals(previous, updated) &&
11431176
previous.UniqueRemoteAddress is { } previousPeer &&

src/core/Akka.Remote/Artery/AssociationState.cs

Lines changed: 43 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -37,18 +37,24 @@ namespace Akka.Remote.Artery
3737
/// </summary>
3838
internal sealed class AssociationState
3939
{
40-
private AssociationState(int incarnation, UniqueAddress? uniqueRemoteAddress, ImmutableHashSet<long> quarantinedUids)
40+
private AssociationState(
41+
int incarnation,
42+
UniqueAddress? uniqueRemoteAddress,
43+
bool outboundHandshakeCompleted,
44+
ImmutableHashSet<long> quarantinedUids)
4145
{
4246
Incarnation = incarnation;
4347
UniqueRemoteAddress = uniqueRemoteAddress;
48+
OutboundHandshakeCompleted = outboundHandshakeCompleted;
4449
QuarantinedUids = quarantinedUids;
4550
}
4651

4752
/// <summary>
4853
/// The initial state for a freshly-materialized association: no peer UID known yet
49-
/// (<c>Associating</c>), incarnation 1, no quarantined UIDs.
54+
/// (<c>Associating</c>), incarnation 1, our own handshake unanswered, no quarantined UIDs.
5055
/// </summary>
51-
public static AssociationState Create() => new(incarnation: 1, uniqueRemoteAddress: null, quarantinedUids: ImmutableHashSet<long>.Empty);
56+
public static AssociationState Create() =>
57+
new(incarnation: 1, uniqueRemoteAddress: null, outboundHandshakeCompleted: false, quarantinedUids: ImmutableHashSet<long>.Empty);
5258

5359
/// <summary>
5460
/// Monotonically increasing incarnation counter. Starts at 1; incremented only when
@@ -63,6 +69,16 @@ private AssociationState(int incarnation, UniqueAddress? uniqueRemoteAddress, Im
6369
/// </summary>
6470
public UniqueAddress? UniqueRemoteAddress { get; }
6571

72+
/// <summary>
73+
/// Whether THIS side's own <see cref="HandshakeReq"/> has been answered for the CURRENT
74+
/// incarnation. Set ONLY by <see cref="CompleteOutboundHandshake"/>; a uid change resets it
75+
/// and <see cref="Quarantine"/> clears it, so it never outlives the incarnation it
76+
/// describes. <see cref="UniqueRemoteAddress"/> cannot stand in for it: the inbound
77+
/// direction (the peer's own Req) sets that field too, and knowing the peer's uid says
78+
/// nothing about whether the peer knows OURS (issue #8496).
79+
/// </summary>
80+
public bool OutboundHandshakeCompleted { get; }
81+
6682
/// <summary>
6783
/// The set of peer UIDs (for this association's remote address) that have been
6884
/// explicitly quarantined. A uid change alone does not add the superseded uid here —
@@ -80,21 +96,37 @@ private AssociationState(int incarnation, UniqueAddress? uniqueRemoteAddress, Im
8096
/// with <paramref name="peer"/>:
8197
/// <list type="bullet">
8298
/// <item><description><c>Associating</c> → <c>Associated</c>: adopts <paramref name="peer"/>, incarnation unchanged.</description></item>
83-
/// <item><description>Same uid as the current <see cref="UniqueRemoteAddress"/>: no-op — returns <c>this</c> (reference-equal, so the CAS loop in <see cref="Association"/> can skip the compare-exchange).</description></item>
99+
/// <item><description>Same uid as the current <see cref="UniqueRemoteAddress"/>: no-op — returns <c>this</c> (reference-equal, so the CAS loop in <see cref="Association"/> can skip the compare-exchange), except for the one-way <see cref="OutboundHandshakeCompleted"/> flip in <see cref="CompleteOutboundHandshake"/>.</description></item>
84100
/// <item><description>Different uid (remote restart): a new incarnation — <see cref="Incarnation"/> + 1, <see cref="UniqueRemoteAddress"/> replaced, <see cref="QuarantinedUids"/> carried over UNCHANGED (the old uid is deliberately not auto-quarantined).</description></item>
85101
/// </list>
86102
/// </summary>
87-
public AssociationState CompleteHandshake(UniqueAddress peer)
103+
public AssociationState CompleteHandshake(UniqueAddress peer) => Apply(peer, answeredOurReq: false);
104+
105+
/// <summary>
106+
/// As <see cref="CompleteHandshake"/>, but for the ONE event that proves the peer has
107+
/// registered our uid: a <see cref="HandshakeRsp"/>, which a peer only sends after handling
108+
/// a <see cref="HandshakeReq"/> of ours. Sets <see cref="OutboundHandshakeCompleted"/> --
109+
/// nothing else does. A same-uid call that only flips that flag still returns a NEW
110+
/// snapshot (it is a real transition, not the documented no-op).
111+
/// </summary>
112+
public AssociationState CompleteOutboundHandshake(UniqueAddress peer) => Apply(peer, answeredOurReq: true);
113+
114+
private AssociationState Apply(UniqueAddress peer, bool answeredOurReq)
88115
{
89116
if (UniqueRemoteAddress is { } current)
90117
{
91118
if (current.Uid == peer.Uid)
92-
return this;
119+
{
120+
if (!answeredOurReq || OutboundHandshakeCompleted)
121+
return this;
122+
123+
return new AssociationState(Incarnation, peer, outboundHandshakeCompleted: true, QuarantinedUids);
124+
}
93125

94-
return new AssociationState(Incarnation + 1, peer, QuarantinedUids);
126+
return new AssociationState(Incarnation + 1, peer, answeredOurReq, QuarantinedUids);
95127
}
96128

97-
return new AssociationState(Incarnation, peer, QuarantinedUids);
129+
return new AssociationState(Incarnation, peer, answeredOurReq, QuarantinedUids);
98130
}
99131

100132
/// <summary>
@@ -113,7 +145,9 @@ public AssociationState CompleteHandshake(UniqueAddress peer)
113145
if (QuarantinedUids.Contains(uid))
114146
return (this, true);
115147

116-
return (new AssociationState(Incarnation, UniqueRemoteAddress, QuarantinedUids.Add(uid)), true);
148+
// OutboundHandshakeCompleted is cleared with the incarnation it describes: this uid is
149+
// cut off, so no later stream may trust "the peer knows us" on its behalf.
150+
return (new AssociationState(Incarnation, UniqueRemoteAddress, outboundHandshakeCompleted: false, QuarantinedUids.Add(uid)), true);
117151
}
118152
}
119153
}

src/core/Akka.Remote/Artery/IInboundContext.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,15 @@ internal interface IInboundContext
4747
/// </summary>
4848
AssociationState CompleteHandshake(UniqueAddress peer);
4949

50+
/// <summary>
51+
/// As <see cref="CompleteHandshake"/>, but for a <see cref="HandshakeRsp"/> -- which the
52+
/// peer only sends after registering the uid in a <see cref="HandshakeReq"/> of OURS. This
53+
/// is the only call that records <see cref="AssociationState.OutboundHandshakeCompleted"/>,
54+
/// the signal an ordinary/large outbound stream needs before releasing user traffic
55+
/// (issue #8496).
56+
/// </summary>
57+
AssociationState CompleteOutboundHandshake(UniqueAddress peer);
58+
5059
/// <summary>
5160
/// Sends <paramref name="message"/> over the control channel to <paramref name="to"/>.
5261
/// Used by <see cref="InboundHandshakeStage"/> to reply with a <see cref="HandshakeRsp"/>.
@@ -129,6 +138,9 @@ public AssociationRegistryInboundContext(
129138
/// <inheritdoc/>
130139
public AssociationState CompleteHandshake(UniqueAddress peer) => _registry.CompleteHandshake(peer.Address, peer);
131140

141+
/// <inheritdoc/>
142+
public AssociationState CompleteOutboundHandshake(UniqueAddress peer) => _registry.CompleteOutboundHandshake(peer.Address, peer);
143+
132144
/// <inheritdoc/>
133145
public void SendControl(Address to, object message) => _sendControl(to, message);
134146

0 commit comments

Comments
 (0)