From 8fd0a01b904b1a18d421913496d3fd489f5f2b1a Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Fri, 25 Sep 2026 12:56:25 +0000 Subject: [PATCH 1/2] Disassociate the wrapped handle when a throttled association stops (#8637) ThrottlerHandle.Disassociate only PoisonPilled its ThrottledAssociation actor, which never tore down the underlying TCP handle - sockets stayed open under the trttl adapter (applied by every MultiNodeSpec) until transport Shutdown force-closed them. ThrottledAssociation.PostStop now disassociates the wrapped handle exactly once, matching canonical Akka's postStop hook; the wrapped handle's Disassociate() is required to be idempotent, so this is safe even after an inbound disassociation. --- .../ThrottlerManagerLifecycleSpec.cs | 38 +++++++++++++++++++ .../Transport/ThrottleTransportAdapter.cs | 12 ++++++ 2 files changed, 50 insertions(+) diff --git a/src/core/Akka.Remote.Tests/Transport/ThrottlerManagerLifecycleSpec.cs b/src/core/Akka.Remote.Tests/Transport/ThrottlerManagerLifecycleSpec.cs index a20094a78da..6defe918658 100644 --- a/src/core/Akka.Remote.Tests/Transport/ThrottlerManagerLifecycleSpec.cs +++ b/src/core/Akka.Remote.Tests/Transport/ThrottlerManagerLifecycleSpec.cs @@ -54,6 +54,9 @@ public StubHandle(Address localAddress, Address remoteAddress) : base(localAddre public bool Disassociated { get; private set; } + // counts calls so tests can tell a single disassociate from a double one + public int DisassociateCallCount { get; private set; } + public override bool Write(ByteString payload) => true; #pragma warning disable CS0672 // the base member is obsolete, but it is the abstract one we have to implement @@ -61,6 +64,7 @@ public override void Disassociate() #pragma warning restore CS0672 { Disassociated = true; + DisassociateCallCount++; } } @@ -188,5 +192,39 @@ await EventFilter.Warning(contains: "received an InboundPayload before it was in originalHandle.Disassociated.Should().BeTrue("the association has to be torn down so remoting can re-establish it"); } + + [Fact(DisplayName = "ThrottlerHandle.Disassociate should close the wrapped handle promptly (#8637)")] + public async Task Should_disassociate_wrapped_handle_on_explicit_disassociate() + { + var manager = StartManager(); + var throttlerHandle = await AssociateAsync(manager); + var throttler = throttlerHandle.ThrottlerActor; + await WatchAsync(throttler); + +#pragma warning disable CS0618 // this is the exact API the issue is about + throttlerHandle.Disassociate(); +#pragma warning restore CS0618 + await ExpectTerminatedAsync(throttler); + + var originalHandle = (StubHandle)throttlerHandle.WrappedHandle; + originalHandle.Disassociated.Should().BeTrue("disassociating the throttled handle must tear down the wrapped connection too"); + } + + [Fact(DisplayName = "ThrottledAssociation should not double-disassociate when the wrapped handle disassociates first")] + public async Task Should_not_double_disassociate_on_inbound_disassociation() + { + var manager = StartManager(); + var throttlerHandle = await AssociateAsync(manager); + var throttler = throttlerHandle.ThrottlerActor; + var originalHandle = (StubHandle)throttlerHandle.WrappedHandle; + await WatchAsync(throttler); + + // simulates the wrapped handle notifying us that the peer already tore the connection down + throttler.Tell(new Disassociated(DisassociateInfo.Unknown)); + await ExpectTerminatedAsync(throttler); + + originalHandle.DisassociateCallCount.Should().Be(1, + "the wrapped handle already disassociated itself; PostStop must not call it again"); + } } } diff --git a/src/core/Akka.Remote/Transport/ThrottleTransportAdapter.cs b/src/core/Akka.Remote/Transport/ThrottleTransportAdapter.cs index 54a1fcfc0a6..3fe51a322f7 100644 --- a/src/core/Akka.Remote/Transport/ThrottleTransportAdapter.cs +++ b/src/core/Akka.Remote/Transport/ThrottleTransportAdapter.cs @@ -1114,6 +1114,18 @@ public ThrottledAssociation(IActorRef manager, IAssociationEventListener associa InitializeFSM(); } + /// + /// Disassociates the wrapped handle whenever this actor stops, for any reason (explicit + /// disassociate, Blackhole, crash, or transport shutdown). Mirrors canonical Akka's + /// ThrottledAssociation.postStop. The wrapped handle's Disassociate() is required to be + /// idempotent, so this is safe even if the peer already tore the connection down first. + /// + protected override void PostStop() + { + OriginalHandle.Disassociate("the owning ThrottledAssociation stopped", _log); + base.PostStop(); + } + private void InitializeFSM() { When(ThrottlerState.WaitExposedHandle, @event => From b3f8c6d6af3058c6086ce3cad2125fd06c6ae344 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Fri, 25 Sep 2026 13:10:53 +0000 Subject: [PATCH 2/2] Address review nits: guard PostStop's base call, drop a redundant Disassociate, rename a misleading test - ThrottledAssociation.PostStop: wrap the wrapped-handle Disassociate in try/finally so base.PostStop() (and OnTermination) always runs. - Remove the now-redundant explicit OriginalHandle.Disassociate() in the "InboundPayload before initialized" handler - PostStop already covers it. - Rename the inbound-disassociation test to describe what it actually checks (PostStop disassociates exactly once), and add a call-count assertion to the pre-existing "payload before handle" test so it would catch the removed redundant call coming back. --- .../Transport/ThrottlerManagerLifecycleSpec.cs | 11 +++++++---- .../Transport/ThrottleTransportAdapter.cs | 12 +++++++++--- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/core/Akka.Remote.Tests/Transport/ThrottlerManagerLifecycleSpec.cs b/src/core/Akka.Remote.Tests/Transport/ThrottlerManagerLifecycleSpec.cs index 6defe918658..965b84b963d 100644 --- a/src/core/Akka.Remote.Tests/Transport/ThrottlerManagerLifecycleSpec.cs +++ b/src/core/Akka.Remote.Tests/Transport/ThrottlerManagerLifecycleSpec.cs @@ -191,6 +191,8 @@ await EventFilter.Warning(contains: "received an InboundPayload before it was in }); originalHandle.Disassociated.Should().BeTrue("the association has to be torn down so remoting can re-establish it"); + // this state used to disassociate directly as well as through PostStop; guards against that regressing + originalHandle.DisassociateCallCount.Should().Be(1, "PostStop alone must be the one that tears the handle down"); } [Fact(DisplayName = "ThrottlerHandle.Disassociate should close the wrapped handle promptly (#8637)")] @@ -210,8 +212,8 @@ public async Task Should_disassociate_wrapped_handle_on_explicit_disassociate() originalHandle.Disassociated.Should().BeTrue("disassociating the throttled handle must tear down the wrapped connection too"); } - [Fact(DisplayName = "ThrottledAssociation should not double-disassociate when the wrapped handle disassociates first")] - public async Task Should_not_double_disassociate_on_inbound_disassociation() + [Fact(DisplayName = "ThrottledAssociation should disassociate the wrapped handle exactly once, via PostStop, on inbound disassociation")] + public async Task Should_disassociate_wrapped_handle_once_on_inbound_disassociation() { var manager = StartManager(); var throttlerHandle = await AssociateAsync(manager); @@ -219,12 +221,13 @@ public async Task Should_not_double_disassociate_on_inbound_disassociation() var originalHandle = (StubHandle)throttlerHandle.WrappedHandle; await WatchAsync(throttler); - // simulates the wrapped handle notifying us that the peer already tore the connection down + // simulates the wrapped handle notifying us that the peer tore the connection down; + // nothing on this path calls Disassociate directly, so PostStop must be the sole caller throttler.Tell(new Disassociated(DisassociateInfo.Unknown)); await ExpectTerminatedAsync(throttler); originalHandle.DisassociateCallCount.Should().Be(1, - "the wrapped handle already disassociated itself; PostStop must not call it again"); + "PostStop must disassociate the wrapped handle exactly once, even though the peer disassociated first"); } } } diff --git a/src/core/Akka.Remote/Transport/ThrottleTransportAdapter.cs b/src/core/Akka.Remote/Transport/ThrottleTransportAdapter.cs index 3fe51a322f7..ef30b98012a 100644 --- a/src/core/Akka.Remote/Transport/ThrottleTransportAdapter.cs +++ b/src/core/Akka.Remote/Transport/ThrottleTransportAdapter.cs @@ -1122,8 +1122,14 @@ public ThrottledAssociation(IActorRef manager, IAssociationEventListener associa /// protected override void PostStop() { - OriginalHandle.Disassociate("the owning ThrottledAssociation stopped", _log); - base.PostStop(); + try + { + OriginalHandle.Disassociate("the owning ThrottledAssociation stopped", _log); + } + finally + { + base.PostStop(); + } } private void InitializeFSM() @@ -1155,7 +1161,7 @@ private void InitializeFSM() _log.Warning( "Throttler for [{0}] received an InboundPayload before it was initialized - most likely restarted. Disassociating so the association can be re-established.", OriginalHandle.RemoteAddress); - OriginalHandle.Disassociate("throttler received inbound data before it was initialized", _log); + // PostStop disassociates the wrapped handle; no need to do it here too return Stop(); }