diff --git a/src/core/Akka.Remote.Tests/AckedDeliverySpec.cs b/src/core/Akka.Remote.Tests/AckedDeliverySpec.cs index 75bd6ad4b83..d861c2ec52f 100644 --- a/src/core/Akka.Remote.Tests/AckedDeliverySpec.cs +++ b/src/core/Akka.Remote.Tests/AckedDeliverySpec.cs @@ -204,6 +204,33 @@ public void SendBuffer_must_keep_NACKed_messages_in_buffer_if_selective_nacks_ar Assert.True(b7.Nacked.Count == 0); } + [Fact] + public void SendBuffer_must_ignore_stale_ack_from_previous_association() + { + // Reproduces GitHub #6414: a fresh send buffer (MaxSeq = -1) receives a stale ACK + // from a previous association. This must be a no-op, not a throw. + var b0 = new AckedSendBuffer(10); + + // ACK[0] from stale receiver state - MaxSeq is -1, CumulativeAck is 0 + var result = b0.Acknowledge(new Ack(new SeqNo(0))); + Assert.Same(b0, result); // should return the same instance (no-op) + } + + [Fact] + public void SendBuffer_must_ignore_stale_ack_when_buffer_has_messages_with_lower_seqnos() + { + // A buffer with some messages receives a stale ACK referencing a higher sequence + // number than anything buffered - should be a no-op. + var b0 = new AckedSendBuffer(10); + var msg0 = Msg(0); + var msg1 = Msg(1); + var b1 = b0.Buffer(msg0).Buffer(msg1); // MaxSeq = 1 + + // Stale ACK referencing SeqNo(5) which we never sent + var result = b1.Acknowledge(new Ack(new SeqNo(5))); + Assert.Same(b1, result); // should return the same instance (no-op) + } + [Fact] public void SendBuffer_must_throw_exception_if_nonbuffered_sequence_number_is_NACKed() { diff --git a/src/core/Akka.Remote/AckedDelivery.cs b/src/core/Akka.Remote/AckedDelivery.cs index 395e7cd06db..89a23255c3c 100644 --- a/src/core/Akka.Remote/AckedDelivery.cs +++ b/src/core/Akka.Remote/AckedDelivery.cs @@ -431,9 +431,13 @@ public AckedSendBuffer(int capacity, SeqNo maxSeq, IImmutableList nacked, IIm /// An updated buffer containing the remaining unacknowledged messages public AckedSendBuffer Acknowledge(Ack ack) { + // If the ACK references a sequence number higher than anything we've sent, + // it's a stale ACK from a previous association. The buffer is empty or has + // only messages with lower sequence numbers — there's nothing to acknowledge. + // Throwing here would cause an irrecoverable quarantine (see GitHub #6414). if (ack.CumulativeAck > MaxSeq) { - throw new ArgumentException(nameof(ack), $"Highest SEQ so far was {MaxSeq} but cumulative ACK is {ack.CumulativeAck}"); + return this; } var newNacked = ack.Nacks.Count == 0 diff --git a/src/core/Akka.Remote/Endpoint.cs b/src/core/Akka.Remote/Endpoint.cs index 281b35d5086..b7d932c3421 100644 --- a/src/core/Akka.Remote/Endpoint.cs +++ b/src/core/Akka.Remote/Endpoint.cs @@ -612,6 +612,12 @@ private void Receiving() try { _resendBuffer = _resendBuffer.Acknowledge(ack); + if (ack.CumulativeAck > _resendBuffer.MaxSeq) + { + _log.Warning( + "Ignoring stale ACK [{0}] for send buffer [{1}] - likely from a previous association to [{2}]", + ack, _resendBuffer, _remoteAddress); + } } catch (Exception ex) {