From 9e1f5bb9dc69b86a15d35ebd499a69857237735f Mon Sep 17 00:00:00 2001 From: Pavel Vostretsov Date: Thu, 16 Jul 2026 08:57:40 +0300 Subject: [PATCH] Close connection on heartbeat OperationTimedOutException, not just SocketException A heartbeat (OptionsRequest) that times out because the peer stopped responding while the TCP connection stays established was previously ignored, since IdleTimeoutHandler only closed the connection for SocketException. This left connections to a dead-but-still-accepting host open indefinitely once application traffic stopped flowing on them, well past the point the host was already marked DOWN via other connections. Co-Authored-By: Claude Sonnet 5 --- .../Core/HeartbeatTests.cs | 48 ++++++++++++++++++- src/Cassandra/Connections/Connection.cs | 2 +- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/src/Cassandra.IntegrationTests/Core/HeartbeatTests.cs b/src/Cassandra.IntegrationTests/Core/HeartbeatTests.cs index 1c18b38fc..3cf682937 100644 --- a/src/Cassandra.IntegrationTests/Core/HeartbeatTests.cs +++ b/src/Cassandra.IntegrationTests/Core/HeartbeatTests.cs @@ -19,6 +19,7 @@ using Cassandra.IntegrationTests.SimulacronAPI.Models.Logs; using Cassandra.IntegrationTests.TestBase; using Cassandra.IntegrationTests.TestClusterManagement.Simulacron; +using Cassandra.SessionManagement; using Cassandra.Tests; using NUnit.Framework; @@ -71,10 +72,55 @@ await TestHelper.RetryAssertAsync( logs = await _testCluster.GetNodes().First() .GetQueriesAsync(null, OptionsQueryType).ConfigureAwait(false); Assert.That(logs.Count, Is.GreaterThan(initialCount)); - }, + }, 500, 20).ConfigureAwait(false); } } + + [Test] + public async Task Connection_Should_Be_Closed_When_Heartbeat_Times_Out_With_Socket_Still_Open() + { + var builder = ClusterBuilder() + .WithPoolingOptions(PoolingOptions.Create() + .SetHeartBeatInterval(2000) + .SetCoreConnectionsPerHost(HostDistance.Local, 1) + .SetMaxConnectionsPerHost(HostDistance.Local, 1)) + .WithSocketOptions(new SocketOptions() + .SetReadTimeoutMillis(2000) + .SetDefunctReadTimeoutThreshold(int.MaxValue)) + .AddContactPoint(_testCluster.InitialContactPoint); + + using (var cluster = builder.Build()) + { + var session = await cluster.ConnectAsync().ConfigureAwait(false); + await session.ExecuteAsync(new SimpleStatement(HeartbeatTests.Query)).ConfigureAwait(false); + + var pool = ((IInternalSession)session).GetPools().Single().Value; + var connection = pool.ConnectionsSnapshot.Single(); + Assert.IsFalse(connection.IsDisposed); + + // Simulacron keeps the TCP connection ESTABLISHED and just stops reading, so the + // heartbeat fails with OperationTimedOutException rather than SocketException. No + // application requests are sent after this point, so only the heartbeat itself can + // detect the unresponsive host and close the connection. + await _testCluster.PauseReadsAsync().ConfigureAwait(false); + try + { + await TestHelper.RetryAssertAsync( + () => + { + Assert.IsTrue(connection.IsDisposed); + return Task.CompletedTask; + }, + 500, + 20).ConfigureAwait(false); + } + finally + { + await _testCluster.ResumeReadsAsync().ConfigureAwait(false); + } + } + } } } \ No newline at end of file diff --git a/src/Cassandra/Connections/Connection.cs b/src/Cassandra/Connections/Connection.cs index 720268ef1..bb4d9e6c5 100644 --- a/src/Cassandra/Connections/Connection.cs +++ b/src/Cassandra/Connections/Connection.cs @@ -438,7 +438,7 @@ private void IdleTimeoutHandler(object state) return TaskHelper.Completed; } Connection.Logger.Warning("Received heartbeat request exception " + error.Exception.ToString()); - if (error.Exception is SocketException) + if (error.Exception is SocketException || error.Exception is OperationTimedOutException) { OnIdleRequestException?.Invoke(error.Exception); }