This is an automated email from the ASF dual-hosted git repository.

cloud-fan pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/spark.git


The following commit(s) were added to refs/heads/master by this push:
     new af739ddfede9 [SPARK-58094][CONNECT] Blocking RPCs hang forever when 
the connection dies mid-wait
af739ddfede9 is described below

commit af739ddfede95be42e18aa9132f256a0a9de4442
Author: Vinod KC <[email protected]>
AuthorDate: Wed Jul 15 11:37:02 2026 +0800

    [SPARK-58094][CONNECT] Blocking RPCs hang forever when the connection dies 
mid-wait
    
    ### What changes were proposed in this pull request?
    
    This PR configures gRPC/HTTP2 keepalive by default on both sides of Spark 
Connect, for both the JVM and Python (PySpark) clients:
    
    - **Client (JVM)** — `SparkConnectClient.scala`'s 
`Configuration.createChannel()`: sets 
keepAliveTime/keepAliveTimeout/keepAliveWithoutCalls on the gRPC channel 
builder. Configurable via new connection-string params grpc_keepalive_enabled / 
grpc_keepalive_time_ms / grpc_keepalive_timeout_ms / 
grpc_keepalive_without_calls (SparkConnectClientParser.scala) and matching 
Builder.grpcKeepAliveEnabled / grpcKeepAliveTimeMs / grpcKeepAliveTimeoutMs / 
grpcKeepAliveWithoutCalls methods. Default [...]
    - **Client (Python)** — `core.py'`s ChannelBuilder: the same four options 
(grpc_keepalive_enabled/_time_ms/_timeout_ms/_without_calls), applied via a new 
_effective_channel_options() that both _insecure_channel/_secure_channel funnel 
through (so any custom ChannelBuilder subclass gets the fix too, not just 
DefaultChannelBuilder), same defaults as the JVM client. An explicitly-set 
channelOptions/setChannelOption() value for one of these keys is never 
overwritten.
    - **Server** — `SparkConnectService.scala'`s startGRPCService(): sets 
keepAliveTime/keepAliveTimeout/permitKeepAliveTime/permitKeepAliveWithoutCalls(true)
 on the NettyServerBuilder, applied only when enabled. New static confs 
spark.connect.grpc.keepAlive.enabled/.time/.timeout (Connect.scala), same 
enabled/60s/20s defaults. permitKeepAliveTime is set equal to the client's 
default keepAliveTime (60s) — otherwise the server's gRPC-library default 
5-minute permitKeepAliveTime would rejec [...]
    - Both sides can be fully disabled independently 
(`spark.connect.grpc.keepAlive.enabled=false` server-side, 
`grpc_keepalive_enabled=false`/`Builder.grpcKeepAliveEnabled(false)` 
client-side), as an escape hatch for environments where this default-on 
behavior change is undesirable (e.g. a server/client environment prone to long 
GC pauses or other stalls that could otherwise trip a false-positive 
disconnect).
    - Documentation updated: docs/configuration.md (new server confs) and 
sql/connect/docs/client-connection-string.md (new client connection-string 
params).
    
    This does not change `awaitTermination()`'s (or any other blocking call's) 
semantics — blocking indefinitely while a request is genuinely still in flight 
on a healthy connection is correct. The gap was purely at the transport layer: 
once keepalive detects a connection that has gone silently dark, the channel 
fails with `UNAVAILABLE`, and the existing bounded retry policy 
(RetryPolicy.scala, maxRetries=15, capped exponential backoff) takes over and 
surfaces a real exception, instead of [...]
    
    ### Why are the changes needed?
    
    `StreamingQuery.awaitTermination()` (and, more generally, any blocking 
Spark Connect RPC) can hang indefinitely and never throw if the network path 
between client and server goes silently dark while the call is blocked — e.g. a 
NAT gateway, load balancer, or corporate proxy silently evicting an idle 
connection mapping without sending a TCP RST/FIN. This is a well-known 
operational gotcha for gRPC deployments behind such middleboxes, and Spark 
Connect configured no keepalive anywhere ( [...]
    
    Root cause: with no keepalive configured, neither endpoint has any way to 
detect that the underlying TCP connection has gone silently dark — both sides 
simply continue believing the RPC is still legitimately in flight, forever. The 
streaming query can (and did, in the reported incident) terminate independently 
on the server in the meantime, but there is no live channel left to deliver 
that information back to the client.
    
    Reproduced end-to-end with two genuinely separate JVM processes (a real 
Spark Connect server and a standalone client app) and a small TCP proxy in 
between, frozen mid-RPC with SIGSTOP to simulate a silently-dropped connection 
without either endpoint's socket seeing a close/reset — see "How was this patch 
tested?" below. Pre-fix, the client's main thread parks forever in 
`ArrayBlockingQueue.take()` (via ExecutePlanResponseReattachableIterator); 
post-fix, keepalive detects the dead conn [...]
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes.
    
    - New configuration, all optional and defaulted to preserve the fixed 
behavior:
      - **Server** (SQLConf): spark.connect.grpc.keepAlive.enabled (default 
true), spark.connect.grpc.keepAlive.time (default 60s), 
spark.connect.grpc.keepAlive.timeout (default 20s).
      - **Client** (connection-string params, JVM Builder methods, and Python 
ChannelBuilder connection-string params): grpc_keepalive_enabled (default 
true), grpc_keepalive_time_ms (default 60000), grpc_keepalive_timeout_ms 
(default 20000), grpc_keepalive_without_calls (default true).
    - Behavior change (default-on): both the Spark Connect client and server 
now send gRPC/HTTP2 keepalive PINGs periodically. Previously, a Spark Connect 
connection that went silently dark mid-RPC would leave the client blocked 
forever with no exception; now, such a connection is detected within roughly 
keepAliveTime + keepAliveTimeout (60s + 20s by default) and the in-flight call 
fails with UNAVAILABLE: Keepalive failed. The connection is likely gone, after 
which Spark Connect's existin [...]
    - This is a behavior change from all previously-released Spark versions 
(keepalive was never configured before); it can be fully reverted via the 
.enabled/grpc_keepalive_enabled flags above for environments where it is 
undesirable.
    
    ### How was this patch tested?
    
    Unit and end-to-end tests :
    
    - `SparkConnectClientBuilderParseTestSuite` (JVM): connection-string/CLI 
parsing of the new keepalive params (including an explicit 
grpc_keepalive_enabled=true case, not just the default and the disabled case), 
and that defaults apply when unset — 20/20.
    - `SparkConnectClientSuite` (JVM), two new tests: a FreezableTcpRelay test 
helper (an in-process byte-forwarding relay that can be frozen without closing 
either socket) sits between a real test client and server; once a blocking RPC 
is in flight, the relay is frozen and the test asserts the client's call fails 
within a bounded time with UNAVAILABLE: Keepalive failed. The connection is 
likely gone instead of hanging past the test's own timeout; a second test 
confirms keepalive does not [...]
    - `SparkConnectServiceKeepAliveSuite` (new, server side): starts the real 
SparkConnectService (not a hand-built test double) with short keepalive confs, 
puts a FreezableTcpRelay in front of it, and confirms both that (a) a blocked 
call fails with the keepalive UNAVAILABLE when keepalive is enabled, and (b) 
disabling spark.connect.grpc.keepAlive.enabled reverts to the pre-fix hang (the 
call stays genuinely blocked well past the window that would have triggered 
detection) .
    - Python `test_connect_channel.py`: defaults, connection-string overrides
    
    Manual end-to-end reproduction, using two genuinely separate JVM/OS 
processes (not an in-process test) plus a small Python TCP proxy in between, to 
faithfully reproduce the client/server RPC boundary and a silently-dropped 
connection:
    
    1. A real Spark Connect server
    2. A standalone Scala client app running a rate-source streaming query that 
fails via assert_true at a chosen row value (simulating a non-retryable source 
error), with a StreamingQueryListener and a watchdog thread, calling 
query.awaitTermination().
    3. The TCP proxy frozen with SIGSTOP shortly after awaitTermination() was 
called (no TCP RST/FIN sent to either endpoint — the same observable behavior 
as a NAT gateway/load balancer silently evicting an idle connection mapping).
    
    Confirmed:
    
    - Pre-fix: the server terminates the query independently a few seconds 
later (confirming the hang is not an exception-propagation bug), but the 
client's awaitTermination() never returns; a thread dump shows the main thread 
permanently parked in ArrayBlockingQueue.take() via 
ExecutePlanResponseReattachableIterator/GrpcRetryHandler$Retrying.retry, with 
no path out.
    - Post-fix: the same scenario instead surfaces a bounded exception. With 
fast (1s/1s) keepalive settings, a thread dump within ~1-2 minutes shows the 
client has alreadymoved off ArrayBlockingQueue.take() into 
`GrpcRetryHandler$Retrying.waitAfterAttempt` (keepalive detected the dead 
connection); the call eventually throws UNAVAILABLE:Keepalive failed. The 
connection is likely gone (observed consistently at ~630-770s end-to-end, 
governed by the existing, unmodified retry policy's own ba [...]
    - The same manual reproduction was repeated against the Python client 
(grpc.insecure_channel/grpc.secure_channel via ChannelBuilder), confirming both 
the original gap(Python builds its own gRPC channel and initially had none of 
this fix) and the fix once applied: awaitTermination() threw 
SparkConnectGrpcException / UNAVAILABLE:Stream removed (ping timeout) — 
grpcio's equivalent wording for the same keepalive-triggered failure.
    
    A minimal, runnable, standalone reproduction (client + server + proxy 
scripts, independent of this Spark checkout's test suite) is attached to 
SPARK-58094 for reviewers who want to reproduce the issue.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Yes. Generated-by: Claude Code (Sonnet 5).
    
    Closes #57202 from vinodkc/connect-streaming-hang-repro.
    
    Authored-by: Vinod KC <[email protected]>
    Signed-off-by: Wenchen Fan <[email protected]>
---
 docs/configuration.md                              |  24 ++
 python/pyspark/sql/connect/client/core.py          |  96 ++++-
 .../sql/tests/connect/test_connect_channel.py      |  73 ++++
 .../SparkConnectClientBuilderParseTestSuite.scala  |  45 +++
 .../connect/client/SparkConnectClientSuite.scala   | 175 +++++++++
 .../sql/connect/client/SparkConnectClient.scala    |  69 +++-
 .../connect/client/SparkConnectClientParser.scala  |  16 +
 .../sql/connect/common/config/ConnectCommon.scala  |   9 +
 sql/connect/docs/client-connection-string.md       |  47 +++
 .../apache/spark/sql/connect/config/Connect.scala  |  43 +++
 .../sql/connect/service/SparkConnectService.scala  |  30 +-
 .../spark/sql/connect/SparkConnectServerTest.scala |  10 +-
 .../SparkConnectServiceKeepAliveSuite.scala        | 412 +++++++++++++++++++++
 13 files changed, 1043 insertions(+), 6 deletions(-)

diff --git a/docs/configuration.md b/docs/configuration.md
index 3eeee90d0778..4999b08908de 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -3561,6 +3561,30 @@ They are typically set via the config file and 
command-line options with `--conf
   <td>Sets the maximum inbound message size for the gRPC requests. Requests 
with a larger payload will fail.</td>
   <td>3.4.0</td>
 </tr>
+<tr>
+  <td><code>spark.connect.grpc.keepAlive.enabled</code></td>
+  <td>
+    true
+  </td>
+  <td>Whether the server sends gRPC/HTTP2 keepalive PINGs to detect and 
terminate silently-dead client connections. Can be turned off as an escape 
hatch, e.g. if it interacts badly with a particular network path, or a server 
environment is prone to stalls (long GC pauses, etc.) long enough to trip 
false-positive disconnects.</td>
+  <td>4.3.0</td>
+</tr>
+<tr>
+  <td><code>spark.connect.grpc.keepAlive.time</code></td>
+  <td>
+    60s
+  </td>
+  <td>Sets the time the server waits for the connection to be idle before 
sending a gRPC/HTTP2 keepalive PING, to detect and terminate a silently-dead 
connection (e.g. after a NAT gateway or load balancer drops an idle connection 
mapping without closing the socket). The server separately tolerates 
client-sent keepalive PINGs no more often than every 10s regardless of this 
setting; a client configured with <code>grpc_keepalive_time_ms</code> below 
that floor will have its connection torn  [...]
+  <td>4.3.0</td>
+</tr>
+<tr>
+  <td><code>spark.connect.grpc.keepAlive.timeout</code></td>
+  <td>
+    20s
+  </td>
+  <td>Sets how long the server waits for a keepalive PING ack before 
considering the connection dead.</td>
+  <td>4.3.0</td>
+</tr>
 <tr>
   <td><code>spark.connect.extensions.relation.classes</code></td>
   <td>
diff --git a/python/pyspark/sql/connect/client/core.py 
b/python/pyspark/sql/connect/client/core.py
index db6067f25e09..1e81e780d167 100644
--- a/python/pyspark/sql/connect/client/core.py
+++ b/python/pyspark/sql/connect/client/core.py
@@ -212,9 +212,23 @@ class ChannelBuilder:
     PARAM_USER_ID = "user_id"
     PARAM_USER_AGENT = "user_agent"
     PARAM_SESSION_ID = "session_id"
+    PARAM_GRPC_KEEPALIVE_ENABLED = "grpc_keepalive_enabled"
+    PARAM_GRPC_KEEPALIVE_TIME_MS = "grpc_keepalive_time_ms"
+    PARAM_GRPC_KEEPALIVE_TIMEOUT_MS = "grpc_keepalive_timeout_ms"
+    PARAM_GRPC_KEEPALIVE_WITHOUT_CALLS = "grpc_keepalive_without_calls"
 
     GRPC_MAX_MESSAGE_LENGTH_DEFAULT = 128 * 1024 * 1024
 
+    # Detects a silently-dead connection (e.g. a NAT gateway/load balancer 
dropping an idle
+    # connection mapping without sending a TCP RST/FIN) via gRPC/HTTP2 
keepalive PINGs, so a
+    # blocked RPC (such as streaming query awaitTermination()) surfaces as 
UNAVAILABLE instead
+    # of hanging forever. Mirrors the JVM client's defaults 
(SparkConnectClient.scala). See
+    # SPARK-58094.
+    GRPC_DEFAULT_KEEPALIVE_ENABLED = True
+    GRPC_DEFAULT_KEEPALIVE_TIME_MS = 60 * 1000
+    GRPC_DEFAULT_KEEPALIVE_TIMEOUT_MS = 20 * 1000
+    GRPC_DEFAULT_KEEPALIVE_WITHOUT_CALLS = True
+
     GRPC_DEFAULT_OPTIONS = [
         ("grpc.max_send_message_length", GRPC_MAX_MESSAGE_LENGTH_DEFAULT),
         ("grpc.max_receive_message_length", GRPC_MAX_MESSAGE_LENGTH_DEFAULT),
@@ -281,7 +295,7 @@ class ChannelBuilder:
         raise PySparkNotImplementedError
 
     def _insecure_channel(self, target: Any, **kwargs: Any) -> grpc.Channel:
-        channel = grpc.insecure_channel(target, options=self._channel_options, 
**kwargs)
+        channel = grpc.insecure_channel(target, 
options=self._effective_channel_options(), **kwargs)
 
         if len(self._interceptors) > 0:
             logger.debug(f"Applying interceptors ({self._interceptors})")
@@ -289,7 +303,9 @@ class ChannelBuilder:
         return channel
 
     def _secure_channel(self, target: Any, credentials: Any, **kwargs: Any) -> 
grpc.Channel:
-        channel = grpc.secure_channel(target, credentials, 
options=self._channel_options, **kwargs)
+        channel = grpc.secure_channel(
+            target, credentials, options=self._effective_channel_options(), 
**kwargs
+        )
 
         if len(self._interceptors) > 0:
             logger.debug(f"Applying interceptors ({self._interceptors})")
@@ -311,6 +327,74 @@ class ChannelBuilder:
             ChannelBuilder.PARAM_TOKEN, 
os.environ.get("SPARK_CONNECT_AUTHENTICATE_TOKEN")
         )
 
+    @property
+    def keepalive_enabled(self) -> bool:
+        """
+        Whether the client sends gRPC/HTTP2 keepalive PINGs to detect a 
silently-dead
+        connection. Enabled by default; can be turned off as an escape hatch, 
e.g. if it
+        interacts badly with a particular network path, or a client 
environment is prone to
+        stalls long enough to trip false-positive disconnects.
+        """
+        return (
+            self.getDefault(
+                ChannelBuilder.PARAM_GRPC_KEEPALIVE_ENABLED,
+                str(ChannelBuilder.GRPC_DEFAULT_KEEPALIVE_ENABLED),
+            ).lower()
+            == "true"
+        )
+
+    @property
+    def keepalive_time_ms(self) -> int:
+        """Idle time (in milliseconds) before sending a gRPC/HTTP2 keepalive 
PING."""
+        return int(
+            self.getDefault(
+                ChannelBuilder.PARAM_GRPC_KEEPALIVE_TIME_MS,
+                ChannelBuilder.GRPC_DEFAULT_KEEPALIVE_TIME_MS,
+            )
+        )
+
+    @property
+    def keepalive_timeout_ms(self) -> int:
+        """Time (in milliseconds) to wait for a keepalive PING ack before 
failing."""
+        return int(
+            self.getDefault(
+                ChannelBuilder.PARAM_GRPC_KEEPALIVE_TIMEOUT_MS,
+                ChannelBuilder.GRPC_DEFAULT_KEEPALIVE_TIMEOUT_MS,
+            )
+        )
+
+    @property
+    def keepalive_without_calls(self) -> bool:
+        """Whether to keep sending keepalive PINGs when there are no in-flight 
RPCs."""
+        return (
+            self.getDefault(
+                ChannelBuilder.PARAM_GRPC_KEEPALIVE_WITHOUT_CALLS,
+                str(ChannelBuilder.GRPC_DEFAULT_KEEPALIVE_WITHOUT_CALLS),
+            ).lower()
+            == "true"
+        )
+
+    def _effective_channel_options(self) -> List[Tuple[str, Any]]:
+        """
+        Returns ``self._channel_options`` with the keepalive options
+        
(:attr:`keepalive_enabled`/:attr:`keepalive_time_ms`/:attr:`keepalive_timeout_ms`/
+        :attr:`keepalive_without_calls`) applied, unless a caller already set 
one of those
+        specific keys explicitly via 
``channelOptions``/:meth:`setChannelOption`, in which case
+        the explicit value wins.
+        """
+        options = list(self._channel_options)
+        if not self.keepalive_enabled:
+            return options
+        existing_keys = {k for k, _ in options}
+        for key, value in (
+            ("grpc.keepalive_time_ms", self.keepalive_time_ms),
+            ("grpc.keepalive_timeout_ms", self.keepalive_timeout_ms),
+            ("grpc.keepalive_permit_without_calls", 1 if 
self.keepalive_without_calls else 0),
+        ):
+            if key not in existing_keys:
+                options.append((key, value))
+        return options
+
     def metadata(self) -> Iterable[Tuple[str, str]]:
         """
         Builds the GRPC specific metadata list to be injected into the 
request. All
@@ -330,6 +414,10 @@ class ChannelBuilder:
                 ChannelBuilder.PARAM_USER_ID,
                 ChannelBuilder.PARAM_USER_AGENT,
                 ChannelBuilder.PARAM_SESSION_ID,
+                ChannelBuilder.PARAM_GRPC_KEEPALIVE_ENABLED,
+                ChannelBuilder.PARAM_GRPC_KEEPALIVE_TIME_MS,
+                ChannelBuilder.PARAM_GRPC_KEEPALIVE_TIMEOUT_MS,
+                ChannelBuilder.PARAM_GRPC_KEEPALIVE_WITHOUT_CALLS,
             ]
         ]
 
@@ -399,6 +487,10 @@ class DefaultChannelBuilder(ChannelBuilder):
     >>> cb = DefaultChannelBuilder("sc://localhost/;use_ssl=true;token=aaa")
     ... cb.secure
     True
+
+    >>> cb = 
DefaultChannelBuilder("sc://localhost/;grpc_keepalive_time_ms=30000")
+    ... cb.keepalive_time_ms
+    30000
     """
 
     @staticmethod
diff --git a/python/pyspark/sql/tests/connect/test_connect_channel.py 
b/python/pyspark/sql/tests/connect/test_connect_channel.py
index 21390d671dda..66a8f0d8abc8 100644
--- a/python/pyspark/sql/tests/connect/test_connect_channel.py
+++ b/python/pyspark/sql/tests/connect/test_connect_channel.py
@@ -129,6 +129,79 @@ class ChannelBuilderTests(unittest.TestCase):
         chan = DefaultChannelBuilder("sc://host/")
         self.assertIsNone(chan.session_id)
 
+    def test_keepalive_defaults(self):
+        # SPARK-58094
+        chan = DefaultChannelBuilder("sc://host")
+        self.assertTrue(chan.keepalive_enabled)
+        self.assertEqual(60 * 1000, chan.keepalive_time_ms)
+        self.assertEqual(20 * 1000, chan.keepalive_timeout_ms)
+        self.assertTrue(chan.keepalive_without_calls)
+
+        options = dict(chan._effective_channel_options())
+        self.assertEqual(60 * 1000, options["grpc.keepalive_time_ms"])
+        self.assertEqual(20 * 1000, options["grpc.keepalive_timeout_ms"])
+        self.assertEqual(1, options["grpc.keepalive_permit_without_calls"])
+
+    def test_keepalive_connection_string_overrides(self):
+        # SPARK-58094
+        chan = DefaultChannelBuilder(
+            
"sc://host/;grpc_keepalive_enabled=false;grpc_keepalive_time_ms=12345;"
+            "grpc_keepalive_timeout_ms=6789;grpc_keepalive_without_calls=false"
+        )
+        self.assertFalse(chan.keepalive_enabled)
+        self.assertEqual(12345, chan.keepalive_time_ms)
+        self.assertEqual(6789, chan.keepalive_timeout_ms)
+        self.assertFalse(chan.keepalive_without_calls)
+
+        # Disabled entirely: none of the keepalive options are applied to the 
channel.
+        options = dict(chan._effective_channel_options())
+        self.assertNotIn("grpc.keepalive_time_ms", options)
+        self.assertNotIn("grpc.keepalive_timeout_ms", options)
+        self.assertNotIn("grpc.keepalive_permit_without_calls", options)
+
+    def test_keepalive_enabled_with_custom_values(self):
+        # SPARK-58094
+        chan = DefaultChannelBuilder(
+            
"sc://host/;grpc_keepalive_enabled=true;grpc_keepalive_time_ms=1000;"
+            "grpc_keepalive_timeout_ms=500;grpc_keepalive_without_calls=false"
+        )
+        self.assertTrue(chan.keepalive_enabled)
+        options = dict(chan._effective_channel_options())
+        self.assertEqual(1000, options["grpc.keepalive_time_ms"])
+        self.assertEqual(500, options["grpc.keepalive_timeout_ms"])
+        self.assertEqual(0, options["grpc.keepalive_permit_without_calls"])
+
+    def test_keepalive_explicit_channel_option_wins(self):
+        # SPARK-58094: an explicitly-provided channel option for one of the 
keepalive keys is
+        # not overwritten/duplicated by the default-derived value.
+        chan = DefaultChannelBuilder("sc://host", [("grpc.keepalive_time_ms", 
999)])
+        options = chan._effective_channel_options()
+        self.assertEqual(
+            [k for k, _ in options].count("grpc.keepalive_time_ms"),
+            1,
+            "only one occurrence, no duplicate",
+        )
+        self.assertEqual(
+            next(v for k, v in options if k == "grpc.keepalive_time_ms"),
+            999,
+            "explicit value is not overwritten by the default",
+        )
+        # The other keepalive keys are still applied normally.
+        self.assertIn("grpc.keepalive_timeout_ms", dict(options))
+
+    def test_keepalive_params_excluded_from_metadata(self):
+        # SPARK-58094
+        chan = DefaultChannelBuilder(
+            
"sc://host/;grpc_keepalive_enabled=false;grpc_keepalive_time_ms=1000;"
+            
"grpc_keepalive_timeout_ms=500;grpc_keepalive_without_calls=false;param1=abc"
+        )
+        md = dict(chan.metadata())
+        self.assertNotIn(ChannelBuilder.PARAM_GRPC_KEEPALIVE_ENABLED, md)
+        self.assertNotIn(ChannelBuilder.PARAM_GRPC_KEEPALIVE_TIME_MS, md)
+        self.assertNotIn(ChannelBuilder.PARAM_GRPC_KEEPALIVE_TIMEOUT_MS, md)
+        self.assertNotIn(ChannelBuilder.PARAM_GRPC_KEEPALIVE_WITHOUT_CALLS, md)
+        self.assertIn("param1", md)
+
     def test_channel_options(self):
         # SPARK-47694
         chan = DefaultChannelBuilder(
diff --git 
a/sql/connect/client/jvm/src/test/scala/org/apache/spark/sql/connect/client/SparkConnectClientBuilderParseTestSuite.scala
 
b/sql/connect/client/jvm/src/test/scala/org/apache/spark/sql/connect/client/SparkConnectClientBuilderParseTestSuite.scala
index ad0f880e8f57..b9b3754252a3 100644
--- 
a/sql/connect/client/jvm/src/test/scala/org/apache/spark/sql/connect/client/SparkConnectClientBuilderParseTestSuite.scala
+++ 
b/sql/connect/client/jvm/src/test/scala/org/apache/spark/sql/connect/client/SparkConnectClientBuilderParseTestSuite.scala
@@ -18,6 +18,7 @@ package org.apache.spark.sql.connect.client
 
 import java.util.UUID
 
+import org.apache.spark.sql.connect.common.config.ConnectCommon
 import org.apache.spark.sql.connect.test.ConnectFunSuite
 
 /**
@@ -49,6 +50,15 @@ class SparkConnectClientBuilderParseTestSuite extends 
ConnectFunSuite {
   argumentTest("user_name", "alice", _.userName.get)
   argumentTest("user_agent", "robert", _.userAgent.split(" ")(0))
   argumentTest("session_id", UUID.randomUUID().toString, _.sessionId.get)
+  argumentTest("grpc_keepalive_enabled", "false", 
_.grpcKeepAliveEnabled.toString)
+  argumentTest("grpc_keepalive_time_ms", "12345", 
_.grpcKeepAliveTimeMs.toString)
+  argumentTest("grpc_keepalive_timeout_ms", "6789", 
_.grpcKeepAliveTimeoutMs.toString)
+  argumentTest("grpc_keepalive_without_calls", "false", 
_.grpcKeepAliveWithoutCalls.toString)
+
+  test("Argument - grpc_keepalive_enabled explicit true") {
+    val builder = build("--grpc_keepalive_enabled", "true")
+    assert(builder.grpcKeepAliveEnabled)
+  }
 
   test("Argument - remote") {
     val builder =
@@ -61,6 +71,41 @@ class SparkConnectClientBuilderParseTestSuite extends 
ConnectFunSuite {
     assert(builder.sessionId.isEmpty)
   }
 
+  test("Argument - remote with keepalive params (disabled)") {
+    val builder = build(
+      "--remote",
+      
"sc://srv.apache.org/;grpc_keepalive_enabled=false;grpc_keepalive_time_ms=15000;"
 +
+        "grpc_keepalive_timeout_ms=5000;grpc_keepalive_without_calls=false")
+    assert(!builder.grpcKeepAliveEnabled)
+    assert(builder.grpcKeepAliveTimeMs === 15000L)
+    assert(builder.grpcKeepAliveTimeoutMs === 5000L)
+    assert(!builder.grpcKeepAliveWithoutCalls)
+  }
+
+  test("Argument - remote with keepalive params (explicitly enabled)") {
+    val builder = build(
+      "--remote",
+      
"sc://srv.apache.org/;grpc_keepalive_enabled=true;grpc_keepalive_time_ms=15000;"
 +
+        "grpc_keepalive_timeout_ms=5000;grpc_keepalive_without_calls=true")
+    assert(builder.grpcKeepAliveEnabled)
+    assert(builder.grpcKeepAliveTimeMs === 15000L)
+    assert(builder.grpcKeepAliveTimeoutMs === 5000L)
+    assert(builder.grpcKeepAliveWithoutCalls)
+  }
+
+  test("keepalive defaults apply when unset") {
+    val builder = build("--host", "localhost")
+    assert(
+      builder.configuration.grpcKeepAliveEnabled === 
ConnectCommon.CONNECT_GRPC_KEEPALIVE_ENABLED)
+    assert(
+      builder.configuration.grpcKeepAliveTimeMs ===
+        ConnectCommon.CONNECT_GRPC_KEEPALIVE_TIME_SECONDS * 1000)
+    assert(
+      builder.configuration.grpcKeepAliveTimeoutMs ===
+        ConnectCommon.CONNECT_GRPC_KEEPALIVE_TIMEOUT_SECONDS * 1000)
+    assert(builder.configuration.grpcKeepAliveWithoutCalls)
+  }
+
   test("Argument - use_ssl") {
     val builder = build("--use_ssl")
     assert(builder.sslEnabled)
diff --git 
a/sql/connect/client/jvm/src/test/scala/org/apache/spark/sql/connect/client/SparkConnectClientSuite.scala
 
b/sql/connect/client/jvm/src/test/scala/org/apache/spark/sql/connect/client/SparkConnectClientSuite.scala
index d270b0544097..fd5be9bb555e 100644
--- 
a/sql/connect/client/jvm/src/test/scala/org/apache/spark/sql/connect/client/SparkConnectClientSuite.scala
+++ 
b/sql/connect/client/jvm/src/test/scala/org/apache/spark/sql/connect/client/SparkConnectClientSuite.scala
@@ -898,6 +898,110 @@ class SparkConnectClientSuite extends ConnectFunSuite {
     }
   }
 
+  test(
+    "SPARK-58094: gRPC keepalive surfaces a bounded failure on a silently 
dropped " +
+      "connection instead of hanging forever") {
+    val requestReceived = new CountDownLatch(1)
+    val serverLatch = new CountDownLatch(1)
+    val slowService = new DummySparkConnectService {
+      override def analyzePlan(
+          request: AnalyzePlanRequest,
+          responseObserver: StreamObserver[AnalyzePlanResponse]): Unit = {
+        requestReceived.countDown()
+        // Blocks far longer than the test's keepalive settings, standing in 
for a query that
+        // is still genuinely running server-side while the client's 
connection goes dark.
+        serverLatch.await(30, TimeUnit.SECONDS)
+        super.analyzePlan(request, responseObserver)
+      }
+    }
+    val keepAliveMs = 300L
+    server = NettyServerBuilder
+      .forPort(0)
+      .addService(slowService)
+      // The server must permit client PINGs at least as frequently as the 
client's own
+      // keepAliveTime, or it will tear down the connection as 
"too_many_pings" even when
+      // healthy. In production (SparkConnectService.scala) this floor is a 
fixed constant
+      // decoupled from any per-connection setting; here it's simply set to 
match the test
+      // client's cadence.
+      .permitKeepAliveTime(keepAliveMs, TimeUnit.MILLISECONDS)
+      .permitKeepAliveWithoutCalls(true)
+      .build()
+      .start()
+    service = slowService
+    val relay = new FreezableTcpRelay(server.getPort)
+    try {
+      client = SparkConnectClient
+        .builder()
+        .connectionString(s"sc://localhost:${relay.port}")
+        .grpcKeepAliveEnabled(true)
+        .grpcKeepAliveTimeMs(keepAliveMs)
+        .grpcKeepAliveTimeoutMs(keepAliveMs)
+        .retryPolicy(RetryPolicy(maxRetries = Some(0), canRetry = _ => false, 
name = "NoRetry"))
+        .build()
+
+      val resultPromise = scala.concurrent.Promise[Unit]()
+      val callThread = new Thread(() => {
+        try {
+          
client.analyze(proto.AnalyzePlanRequest.newBuilder().setSessionId("abc123").build())
+          resultPromise.success(())
+        } catch {
+          case t: Throwable => resultPromise.failure(t)
+        }
+      })
+      callThread.setDaemon(true)
+      callThread.start()
+
+      assert(requestReceived.await(10, TimeUnit.SECONDS), "server never 
received the request")
+      // Freeze the relay without closing either socket, simulating a NAT 
gateway/load balancer
+      // silently dropping an idle connection mapping (no TCP RST/FIN to 
either endpoint).
+      relay.freeze()
+
+      // Bounded by Await's timeout: without the keepalive fix this never 
completes and the
+      // test would fail with a TimeoutException instead of surfacing 
UNAVAILABLE.
+      // scalastyle:off awaitresult
+      val ex = intercept[SparkException] {
+        scala.concurrent.Await.result(resultPromise.future, FiniteDuration(15, 
TimeUnit.SECONDS))
+      }
+      // scalastyle:on awaitresult
+      // A keepalive-triggered UNAVAILABLE carries no wrapped cause (same as 
DEADLINE_EXCEEDED,
+      // see GrpcExceptionConverter.toThrowable), so the status 
code/description is only in the
+      // message.
+      assert(ex.getMessage.contains("UNAVAILABLE"))
+      assert(ex.getMessage.contains("Keepalive failed"))
+    } finally {
+      serverLatch.countDown()
+      relay.close()
+    }
+  }
+
+  test("SPARK-58094: keepalive does not disrupt a healthy long-lived 
connection") {
+    val keepAliveMs = 300L
+    server = NettyServerBuilder
+      .forPort(0)
+      .addService(new DummySparkConnectService)
+      .permitKeepAliveTime(keepAliveMs, TimeUnit.MILLISECONDS)
+      .permitKeepAliveWithoutCalls(true)
+      .build()
+      .start()
+    client = SparkConnectClient
+      .builder()
+      .connectionString(s"sc://localhost:${server.getPort}")
+      .grpcKeepAliveEnabled(true)
+      .grpcKeepAliveTimeMs(keepAliveMs)
+      .grpcKeepAliveTimeoutMs(keepAliveMs)
+      .build()
+
+    // Several calls spaced out well beyond the keepalive interval: if 
permitKeepAliveTime were
+    // mistuned (e.g. left at the gRPC default of 5 minutes while the client 
pings every 300ms),
+    // the server would tear the connection down as "too_many_pings" and these 
would fail.
+    for (i <- 1 to 3) {
+      val response =
+        
client.analyze(proto.AnalyzePlanRequest.newBuilder().setSessionId(s"abc$i").build())
+      assert(response.getSessionId === s"abc$i")
+      Thread.sleep(keepAliveMs * 2)
+    }
+  }
+
   test("analyzePlan with short deadline fires, then succeeds after disabling 
deadlines") {
     val latch = new CountDownLatch(1)
     val slowService = new DummySparkConnectService {
@@ -1067,6 +1171,77 @@ class SparkConnectClientSuite extends ConnectFunSuite {
   }
 }
 
+/**
+ * A minimal bidirectional TCP relay that can be "frozen" mid-connection 
without closing either
+ * side's socket -- simulating a NAT gateway/load balancer/corporate proxy 
that silently drops an
+ * idle connection's mapping (no TCP RST/FIN sent to either endpoint). While 
frozen, no bytes are
+ * read from or written to either socket, so gRPC/HTTP2 keepalive PING frames 
also stop flowing,
+ * exactly as they would over a genuinely dead network path.
+ */
+private class FreezableTcpRelay(targetPort: Int) {
+  private val listener = new java.net.ServerSocket(0)
+  @volatile private var frozen = false
+  private val sockets = mutable.ListBuffer[java.net.Socket]()
+
+  private val acceptThread = new Thread(() => {
+    try {
+      while (true) {
+        val clientSocket = listener.accept()
+        val serverSocket = new java.net.Socket("127.0.0.1", targetPort)
+        sockets.synchronized {
+          sockets += clientSocket
+          sockets += serverSocket
+        }
+        pump(clientSocket, serverSocket)
+        pump(serverSocket, clientSocket)
+      }
+    } catch {
+      case _: java.io.IOException => // listener closed, relay shutting down
+    }
+  })
+  acceptThread.setDaemon(true)
+  acceptThread.start()
+
+  def port: Int = listener.getLocalPort
+
+  // Once frozen, bytes read from either socket are dropped rather than 
forwarded: no data
+  // (including keepalive PING/PONG frames) reaches the other side, but 
neither socket is
+  // closed -- the same observable behavior as a middlebox silently 
black-holing the connection.
+  def freeze(): Unit = frozen = true
+
+  private def pump(src: java.net.Socket, dst: java.net.Socket): Unit = {
+    val t = new Thread(() => {
+      try {
+        val in = src.getInputStream
+        val out = dst.getOutputStream
+        val buf = new Array[Byte](8192)
+        var n = 0
+        while ({ n = in.read(buf); n != -1 }) {
+          if (!frozen) {
+            out.write(buf, 0, n)
+            out.flush()
+          }
+        }
+      } catch {
+        case _: java.io.IOException => // socket closed, nothing left to pump
+      }
+    })
+    t.setDaemon(true)
+    t.start()
+  }
+
+  def close(): Unit = {
+    listener.close()
+    sockets.synchronized(sockets.foreach { s =>
+      try {
+        s.close()
+      } catch {
+        case _: java.io.IOException =>
+      }
+    })
+  }
+}
+
 class DummySparkConnectService() extends 
SparkConnectServiceGrpc.SparkConnectServiceImplBase {
 
   private var inputPlan: proto.Plan = _
diff --git 
a/sql/connect/common/src/main/scala/org/apache/spark/sql/connect/client/SparkConnectClient.scala
 
b/sql/connect/common/src/main/scala/org/apache/spark/sql/connect/client/SparkConnectClient.scala
index 7bd7d0e4e979..8f449207b796 100644
--- 
a/sql/connect/common/src/main/scala/org/apache/spark/sql/connect/client/SparkConnectClient.scala
+++ 
b/sql/connect/common/src/main/scala/org/apache/spark/sql/connect/client/SparkConnectClient.scala
@@ -20,7 +20,7 @@ package org.apache.spark.sql.connect.client
 import java.net.URI
 import java.nio.charset.StandardCharsets.UTF_8
 import java.util.{Base64, Locale, UUID}
-import java.util.concurrent.Executor
+import java.util.concurrent.{Executor, TimeUnit}
 
 import scala.collection.mutable
 import scala.jdk.CollectionConverters._
@@ -816,6 +816,10 @@ object SparkConnectClient {
       val PARAM_USER_AGENT = "user_agent"
       val PARAM_SESSION_ID = "session_id"
       val PARAM_GRPC_MAX_MESSAGE_SIZE = "grpc_max_message_size"
+      val PARAM_GRPC_KEEPALIVE_ENABLED = "grpc_keepalive_enabled"
+      val PARAM_GRPC_KEEPALIVE_TIME_MS = "grpc_keepalive_time_ms"
+      val PARAM_GRPC_KEEPALIVE_TIMEOUT_MS = "grpc_keepalive_timeout_ms"
+      val PARAM_GRPC_KEEPALIVE_WITHOUT_CALLS = "grpc_keepalive_without_calls"
     }
 
     private def verifyURI(uri: URI): Unit = {
@@ -878,6 +882,48 @@ object SparkConnectClient {
 
     def grpcMaxRecursionLimit: Int = _configuration.grpcMaxRecursionLimit
 
+    /**
+     * Whether the client sends gRPC/HTTP2 keepalive PINGs to detect a 
silently-dead connection
+     * (see `grpcKeepAliveTimeMs`/`grpcKeepAliveTimeoutMs`). Enabled by 
default; can be turned off
+     * as an escape hatch, e.g. if it interacts badly with a particular 
network path, or a client
+     * environment is prone to stalls long enough to trip false-positive 
disconnects.
+     */
+    def grpcKeepAliveEnabled(enabled: Boolean): Builder = {
+      _configuration = _configuration.copy(grpcKeepAliveEnabled = enabled)
+      this
+    }
+
+    def grpcKeepAliveEnabled: Boolean = _configuration.grpcKeepAliveEnabled
+
+    def grpcKeepAliveTimeMs(timeMs: Long): Builder = {
+      _configuration = _configuration.copy(grpcKeepAliveTimeMs = timeMs)
+      this
+    }
+
+    def grpcKeepAliveTimeMs: Long = _configuration.grpcKeepAliveTimeMs
+
+    def grpcKeepAliveTimeoutMs(timeoutMs: Long): Builder = {
+      _configuration = _configuration.copy(grpcKeepAliveTimeoutMs = timeoutMs)
+      this
+    }
+
+    def grpcKeepAliveTimeoutMs: Long = _configuration.grpcKeepAliveTimeoutMs
+
+    /**
+     * Whether to keep sending keepalive PINGs when there are no in-flight 
RPCs on the channel.
+     * Defaults to true, so a long-idle connection (e.g. a parked/unused 
session) is still
+     * monitored and not just calls that are actively blocked. Set to false to 
avoid the resulting
+     * steady background PING traffic for deployments with very large numbers 
of mostly-idle
+     * connections, at the cost of only detecting a dead connection once the 
next RPC is attempted
+     * on it.
+     */
+    def grpcKeepAliveWithoutCalls(enabled: Boolean): Builder = {
+      _configuration = _configuration.copy(grpcKeepAliveWithoutCalls = enabled)
+      this
+    }
+
+    def grpcKeepAliveWithoutCalls: Boolean = 
_configuration.grpcKeepAliveWithoutCalls
+
     def option(key: String, value: String): Builder = {
       _configuration = _configuration.copy(metadata = _configuration.metadata 
+ ((key, value)))
       this
@@ -905,6 +951,11 @@ object SparkConnectClient {
             if (java.lang.Boolean.valueOf(value)) enableSsl() else disableSsl()
           case URIParams.PARAM_SESSION_ID => sessionId(value)
           case URIParams.PARAM_GRPC_MAX_MESSAGE_SIZE => 
grpcMaxMessageSize(value.toInt)
+          case URIParams.PARAM_GRPC_KEEPALIVE_ENABLED => 
grpcKeepAliveEnabled(value.toBoolean)
+          case URIParams.PARAM_GRPC_KEEPALIVE_TIME_MS => 
grpcKeepAliveTimeMs(value.toLong)
+          case URIParams.PARAM_GRPC_KEEPALIVE_TIMEOUT_MS => 
grpcKeepAliveTimeoutMs(value.toLong)
+          case URIParams.PARAM_GRPC_KEEPALIVE_WITHOUT_CALLS =>
+            grpcKeepAliveWithoutCalls(value.toBoolean)
           case _ => option(key, value)
         }
       }
@@ -1048,6 +1099,10 @@ object SparkConnectClient {
       sessionId: Option[String] = None,
       grpcMaxMessageSize: Int = ConnectCommon.CONNECT_GRPC_MAX_MESSAGE_SIZE,
       grpcMaxRecursionLimit: Int = 
ConnectCommon.CONNECT_GRPC_MARSHALLER_RECURSION_LIMIT,
+      grpcKeepAliveEnabled: Boolean = 
ConnectCommon.CONNECT_GRPC_KEEPALIVE_ENABLED,
+      grpcKeepAliveTimeMs: Long = 
ConnectCommon.CONNECT_GRPC_KEEPALIVE_TIME_SECONDS * 1000,
+      grpcKeepAliveTimeoutMs: Long = 
ConnectCommon.CONNECT_GRPC_KEEPALIVE_TIMEOUT_SECONDS * 1000,
+      grpcKeepAliveWithoutCalls: Boolean = 
ConnectCommon.CONNECT_GRPC_KEEPALIVE_WITHOUT_CALLS,
       allowArrowBatchChunking: Boolean = true,
       preferredArrowChunkSize: Option[Int] = None) {
 
@@ -1098,6 +1153,18 @@ object SparkConnectClient {
       interceptors.foreach(channelBuilder.intercept(_))
 
       channelBuilder.maxInboundMessageSize(grpcMaxMessageSize)
+      if (grpcKeepAliveEnabled) {
+        // Detects a silently-dead connection (e.g. a NAT gateway/load 
balancer dropping an idle
+        // connection mapping without sending a TCP RST/FIN) so a blocked RPC 
(such as streaming
+        // query awaitTermination()) surfaces as UNAVAILABLE instead of 
hanging forever. Note: a
+        // stall on either end longer than keepAliveTime + keepAliveTimeout 
(e.g. a long JVM GC
+        // pause) can also cause an otherwise-healthy connection to be dropped 
-- raise both
+        // values (or disable via grpcKeepAliveEnabled) in environments prone 
to long GC pauses
+        // or other event-loop stalls.
+        channelBuilder.keepAliveTime(grpcKeepAliveTimeMs, 
TimeUnit.MILLISECONDS)
+        channelBuilder.keepAliveTimeout(grpcKeepAliveTimeoutMs, 
TimeUnit.MILLISECONDS)
+        channelBuilder.keepAliveWithoutCalls(grpcKeepAliveWithoutCalls)
+      }
       channelBuilder.build()
     }
 
diff --git 
a/sql/connect/common/src/main/scala/org/apache/spark/sql/connect/client/SparkConnectClientParser.scala
 
b/sql/connect/common/src/main/scala/org/apache/spark/sql/connect/client/SparkConnectClientParser.scala
index 7e137a6a3e05..629b6d237e5c 100644
--- 
a/sql/connect/common/src/main/scala/org/apache/spark/sql/connect/client/SparkConnectClientParser.scala
+++ 
b/sql/connect/common/src/main/scala/org/apache/spark/sql/connect/client/SparkConnectClientParser.scala
@@ -42,6 +42,10 @@ private[sql] object SparkConnectClientParser {
        |   --user_agent USER_AGENT      The User-Agent Client information 
(only intended for logging purposes by the server).
        |   --session_id SESSION_ID      Session Id of the user connecting.
        |   --grpc_max_message_size SIZE Maximum message size allowed for gRPC 
messages in bytes.
+       |   --grpc_keepalive_enabled BOOL Whether to enable gRPC/HTTP2 
keepalive PINGs.
+       |   --grpc_keepalive_time_ms MS  Idle time before sending a gRPC/HTTP2 
keepalive PING.
+       |   --grpc_keepalive_timeout_ms MS Time to wait for a keepalive PING 
ack before failing.
+       |   --grpc_keepalive_without_calls BOOL Whether to keep sending 
keepalive PINGs when idle.
        |   --option KEY=VALUE           Key-value pair that is used to further 
configure the session.
      """.stripMargin
   // scalastyle:on line.size.limit
@@ -92,6 +96,18 @@ private[sql] object SparkConnectClientParser {
       case "--grpc_max_message_size" :: tail =>
         val (value, remainder) = extract("--grpc_max_message_size", tail)
         parse(remainder, builder.grpcMaxMessageSize(value.toInt))
+      case "--grpc_keepalive_enabled" :: tail =>
+        val (value, remainder) = extract("--grpc_keepalive_enabled", tail)
+        parse(remainder, builder.grpcKeepAliveEnabled(value.toBoolean))
+      case "--grpc_keepalive_time_ms" :: tail =>
+        val (value, remainder) = extract("--grpc_keepalive_time_ms", tail)
+        parse(remainder, builder.grpcKeepAliveTimeMs(value.toLong))
+      case "--grpc_keepalive_timeout_ms" :: tail =>
+        val (value, remainder) = extract("--grpc_keepalive_timeout_ms", tail)
+        parse(remainder, builder.grpcKeepAliveTimeoutMs(value.toLong))
+      case "--grpc_keepalive_without_calls" :: tail =>
+        val (value, remainder) = extract("--grpc_keepalive_without_calls", 
tail)
+        parse(remainder, builder.grpcKeepAliveWithoutCalls(value.toBoolean))
       case unsupported :: _ =>
         throw new IllegalArgumentException(s"$unsupported is an unsupported 
argument.")
     }
diff --git 
a/sql/connect/common/src/main/scala/org/apache/spark/sql/connect/common/config/ConnectCommon.scala
 
b/sql/connect/common/src/main/scala/org/apache/spark/sql/connect/common/config/ConnectCommon.scala
index e244fd13595b..ba2aa09435d4 100644
--- 
a/sql/connect/common/src/main/scala/org/apache/spark/sql/connect/common/config/ConnectCommon.scala
+++ 
b/sql/connect/common/src/main/scala/org/apache/spark/sql/connect/common/config/ConnectCommon.scala
@@ -21,4 +21,13 @@ private[sql] object ConnectCommon {
   val CONNECT_GRPC_PORT_MAX_RETRIES: Int = 0
   val CONNECT_GRPC_MAX_MESSAGE_SIZE: Int = 128 * 1024 * 1024
   val CONNECT_GRPC_MARSHALLER_RECURSION_LIMIT: Int = 1024
+  // Detects a silently-dead connection (e.g. a NAT gateway/load balancer 
dropping an idle
+  // connection mapping without sending a TCP RST/FIN) via gRPC/HTTP2 
keepalive PINGs, so a
+  // blocked RPC surfaces as UNAVAILABLE instead of hanging forever. Guarded by
+  // CONNECT_GRPC_KEEPALIVE_ENABLED so it can be turned off entirely as an 
escape hatch (e.g. if
+  // it interacts badly with a particular network path, or with long GC 
pauses).
+  val CONNECT_GRPC_KEEPALIVE_ENABLED: Boolean = true
+  val CONNECT_GRPC_KEEPALIVE_TIME_SECONDS: Long = 60
+  val CONNECT_GRPC_KEEPALIVE_TIMEOUT_SECONDS: Long = 20
+  val CONNECT_GRPC_KEEPALIVE_WITHOUT_CALLS: Boolean = true
 }
diff --git a/sql/connect/docs/client-connection-string.md 
b/sql/connect/docs/client-connection-string.md
index f78a91f45f84..7d246434ddd3 100644
--- a/sql/connect/docs/client-connection-string.md
+++ b/sql/connect/docs/client-connection-string.md
@@ -109,6 +109,42 @@ sc://host:port/;param1=value;param2=value
     <i>Default: </i><pre> 128 * 1024 * 1024</pre></td>
     <td><pre>grpc_max_message_size=134217728</pre></td>
   </tr>
+  <tr>
+    <td>grpc_keepalive_enabled</td>
+    <td>Boolean</td>
+    <td>Whether the client sends gRPC/HTTP2 keepalive PINGs to detect a 
silently-dead
+    connection (e.g. a NAT gateway or load balancer dropping an idle 
connection mapping
+    without closing the socket), so a blocked call fails with an error instead 
of hanging
+    forever. Can be turned off as an escape hatch, e.g. in environments prone 
to long stalls
+    (GC pauses, etc.) that could otherwise trip a false-positive 
disconnect.<br/>
+    <i>Default: </i><pre>true</pre></td>
+    <td><pre>grpc_keepalive_enabled=false</pre></td>
+  </tr>
+  <tr>
+    <td>grpc_keepalive_time_ms</td>
+    <td>Numeric</td>
+    <td>Idle time (in milliseconds) before the client sends a keepalive PING. 
A Spark Connect
+    server tolerates client PINGs no more often than every 10s; setting this 
below that floor
+    will get the connection torn down as "too_many_pings".<br/>
+    <i>Default: </i><pre>60000</pre></td>
+    <td><pre>grpc_keepalive_time_ms=30000</pre></td>
+  </tr>
+  <tr>
+    <td>grpc_keepalive_timeout_ms</td>
+    <td>Numeric</td>
+    <td>Time (in milliseconds) the client waits for a keepalive PING ack 
before considering
+    the connection dead.<br/>
+    <i>Default: </i><pre>20000</pre></td>
+    <td><pre>grpc_keepalive_timeout_ms=10000</pre></td>
+  </tr>
+  <tr>
+    <td>grpc_keepalive_without_calls</td>
+    <td>Boolean</td>
+    <td>Whether to keep sending keepalive PINGs when there are no in-flight 
RPCs on the
+    connection.<br/>
+    <i>Default: </i><pre>true</pre></td>
+    <td><pre>grpc_keepalive_without_calls=false</pre></td>
+  </tr>
 </table>
 
 ## Examples
@@ -132,6 +168,17 @@ server_url = "sc://myhost.com:443/;use_ssl=true"
 server_url = "sc://myhost.com:443/;use_ssl=true;token=ABCDEFG"
 ```
 
+The next example tunes the gRPC keepalive settings, e.g. to detect a dead 
connection faster
+than the 60s/20s default, or to disable it entirely.
+
+```python
+server_url = 
"sc://myhost.com:443/;grpc_keepalive_time_ms=30000;grpc_keepalive_timeout_ms=10000"
+```
+
+```python
+server_url = "sc://myhost.com:443/;grpc_keepalive_enabled=false"
+```
+
 ### Invalid Examples
 
 As mentioned above, Spark Connect uses a regular GRPC client and the server 
path
diff --git 
a/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/config/Connect.scala
 
b/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/config/Connect.scala
index e2d496239d29..f5a71a667465 100644
--- 
a/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/config/Connect.scala
+++ 
b/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/config/Connect.scala
@@ -87,6 +87,49 @@ object Connect {
       .intConf
       .createWithDefault(ConnectCommon.CONNECT_GRPC_MARSHALLER_RECURSION_LIMIT)
 
+  val CONNECT_GRPC_KEEPALIVE_ENABLED =
+    buildStaticConf("spark.connect.grpc.keepAlive.enabled")
+      .doc(
+        "Whether the server sends gRPC/HTTP2 keepalive PINGs to detect and 
terminate " +
+          "silently-dead client connections (see 
spark.connect.grpc.keepAlive.time / " +
+          ".timeout). Enabled by default; can be turned off as an escape 
hatch, e.g. if it " +
+          "interacts badly with a particular network path, or a server 
environment is prone " +
+          "to stalls (long GC pauses, etc.) long enough to trip false-positive 
disconnects. " +
+          "This only controls the server's own dead-client detection; the 
server's tolerance " +
+          "of client-initiated keepalive PINGs is " +
+          "independent and always in effect, so that a default-on client is 
never rejected " +
+          "with too_many_pings regardless of this setting.")
+      .version("4.3.0")
+      .booleanConf
+      .createWithDefault(ConnectCommon.CONNECT_GRPC_KEEPALIVE_ENABLED)
+
+  val CONNECT_GRPC_KEEPALIVE_TIME =
+    buildStaticConf("spark.connect.grpc.keepAlive.time")
+      .doc(
+        "Sets the time the server waits for the connection to be idle before 
sending a " +
+          "gRPC/HTTP2 keepalive PING, to detect and terminate a silently-dead 
connection " +
+          "(e.g. after a NAT gateway or load balancer drops an idle connection 
mapping " +
+          "without closing the socket). The server separately tolerates 
client-sent keepalive " +
+          "PINGs no more often than every 10s regardless of this setting. 
Combined with " +
+          "spark.connect.grpc.keepAlive.timeout, this bounds how long a 
connection can be idle " +
+          "before either side may declare it dead; a stall on either end (e.g. 
a long JVM GC " +
+          "pause) longer than that combined window can cause an 
otherwise-healthy connection " +
+          "to be dropped, so raise both values in environments prone to long 
GC pauses or " +
+          "other event-loop stalls.")
+      .version("4.3.0")
+      .timeConf(TimeUnit.SECONDS)
+      .createWithDefault(ConnectCommon.CONNECT_GRPC_KEEPALIVE_TIME_SECONDS)
+
+  val CONNECT_GRPC_KEEPALIVE_TIMEOUT =
+    buildStaticConf("spark.connect.grpc.keepAlive.timeout")
+      .doc(
+        "Sets how long the server waits for a keepalive PING ack before 
considering the " +
+          "connection dead. See spark.connect.grpc.keepAlive.time for the 
combined-window " +
+          "caveat about long GC pauses or other stalls.")
+      .version("4.3.0")
+      .timeConf(TimeUnit.SECONDS)
+      .createWithDefault(ConnectCommon.CONNECT_GRPC_KEEPALIVE_TIMEOUT_SECONDS)
+
   val CONNECT_SESSION_MANAGER_DEFAULT_SESSION_TIMEOUT =
     buildStaticConf("spark.connect.session.manager.defaultSessionTimeout")
       .internal()
diff --git 
a/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/service/SparkConnectService.scala
 
b/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/service/SparkConnectService.scala
index f2dd9c1b1cef..c76794e3b6ec 100644
--- 
a/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/service/SparkConnectService.scala
+++ 
b/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/service/SparkConnectService.scala
@@ -41,7 +41,7 @@ import org.apache.spark.scheduler.{LiveListenerBus, 
SparkListenerEvent}
 import org.apache.spark.sql.SparkSession
 import org.apache.spark.sql.classic.ClassicConversions._
 import org.apache.spark.sql.connect.IllegalStateErrors
-import org.apache.spark.sql.connect.config.Connect.{getAuthenticateToken, 
CONNECT_GRPC_BINDING_ADDRESS, CONNECT_GRPC_BINDING_PORT, 
CONNECT_GRPC_MARSHALLER_RECURSION_LIMIT, CONNECT_GRPC_MAX_INBOUND_MESSAGE_SIZE, 
CONNECT_GRPC_PORT_MAX_RETRIES}
+import org.apache.spark.sql.connect.config.Connect.{getAuthenticateToken, 
CONNECT_GRPC_BINDING_ADDRESS, CONNECT_GRPC_BINDING_PORT, 
CONNECT_GRPC_KEEPALIVE_ENABLED, CONNECT_GRPC_KEEPALIVE_TIME, 
CONNECT_GRPC_KEEPALIVE_TIMEOUT, CONNECT_GRPC_MARSHALLER_RECURSION_LIMIT, 
CONNECT_GRPC_MAX_INBOUND_MESSAGE_SIZE, CONNECT_GRPC_PORT_MAX_RETRIES}
 import org.apache.spark.sql.connect.execution.ConnectProgressExecutionListener
 import org.apache.spark.sql.connect.ui.{SparkConnectServerAppStatusStore, 
SparkConnectServerListener, SparkConnectServerTab}
 import org.apache.spark.sql.connect.utils.ErrorUtils
@@ -314,6 +314,14 @@ class SparkConnectService(debug: Boolean) extends 
AsyncService with BindableServ
  */
 object SparkConnectService extends Logging {
 
+  // Floor for permitKeepAliveTime: the minimum interval the server tolerates 
between a client's
+  // keepalive PINGs before striking the connection as "too_many_pings" (gRFC 
A8). This is
+  // independent of the server's own keepAlive.time (its probe interval for 
detecting dead
+  // clients) and must stay well below any supported client 
grpc_keepalive_time_ms, not derived
+  // from it -- otherwise a faster client (or an operator raising 
keepAlive.time) gets its
+  // healthy connection torn down. 10s leaves a wide margin below the 60s 
client/server default.
+  private[connect] val GRPC_KEEPALIVE_PERMIT_TIME_SECONDS: Long = 10
+
   private[connect] var server: Server = _
   private[connect] var bindingAddress: InetSocketAddress = _
 
@@ -408,7 +416,25 @@ object SparkConnectService extends Logging {
         case _ => NettyServerBuilder.forPort(port)
       }
       
sb.maxInboundMessageSize(SparkEnv.get.conf.get(CONNECT_GRPC_MAX_INBOUND_MESSAGE_SIZE).toInt)
-        .addService(sparkConnectService)
+      if (SparkEnv.get.conf.get(CONNECT_GRPC_KEEPALIVE_ENABLED)) {
+        val keepAliveTimeSeconds = 
SparkEnv.get.conf.get(CONNECT_GRPC_KEEPALIVE_TIME)
+        // Detects a silently-dead client connection (e.g. a NAT gateway/load 
balancer dropping
+        // an idle connection mapping without sending a TCP RST/FIN) via 
gRPC/HTTP2 keepalive
+        // PINGs.
+        sb.keepAliveTime(keepAliveTimeSeconds, TimeUnit.SECONDS)
+        sb.keepAliveTimeout(
+          SparkEnv.get.conf.get(CONNECT_GRPC_KEEPALIVE_TIMEOUT),
+          TimeUnit.SECONDS)
+      }
+      // Independent of the server's own dead-client detection above (gRFC A8: 
these are
+      // separate axes), this tolerates client-initiated keepalive PINGs so a 
default-on client
+      // (which pings regardless of this server's 
CONNECT_GRPC_KEEPALIVE_ENABLED setting) is
+      // never hit with a "too_many_pings" GOAWAY on an otherwise-healthy 
connection. Left
+      // ungated so disabling only this server's own keepalive detection 
cannot regress a
+      // healthy connection to the client into disruptive reconnects.
+      sb.permitKeepAliveTime(GRPC_KEEPALIVE_PERMIT_TIME_SECONDS, 
TimeUnit.SECONDS)
+      sb.permitKeepAliveWithoutCalls(true)
+      sb.addService(sparkConnectService)
 
       getAuthenticateToken.foreach { token =>
         sb.intercept(new PreSharedKeyAuthenticationInterceptor(token))
diff --git 
a/sql/connect/server/src/test/scala/org/apache/spark/sql/connect/SparkConnectServerTest.scala
 
b/sql/connect/server/src/test/scala/org/apache/spark/sql/connect/SparkConnectServerTest.scala
index d4ecbca28e9d..c935370f0643 100644
--- 
a/sql/connect/server/src/test/scala/org/apache/spark/sql/connect/SparkConnectServerTest.scala
+++ 
b/sql/connect/server/src/test/scala/org/apache/spark/sql/connect/SparkConnectServerTest.scala
@@ -54,13 +54,21 @@ trait SparkConnectServerTest extends SharedSparkSession {
 
   val allocator = new RootAllocator()
 
+  // Additional server-side confs to set (via withSparkEnvConfs) before 
starting the real
+  // service. Override in subclasses that need to exercise non-default Connect 
server behavior
+  // (e.g. tuning gRPC keepalive) through the actual production 
startGRPCService() wiring,
+  // rather than only through hand-built test doubles.
+  protected def extraServerConfs: Seq[(String, String)] = Seq.empty
+
   override def beforeAll(): Unit = {
     super.beforeAll()
     // Other suites using mocks leave a mess in the global executionManager,
     // shut it down so that it's cleared before starting server.
     SparkConnectService.executionManager.shutdown()
     // Start the real service.
-    withSparkEnvConfs((Connect.CONNECT_GRPC_BINDING_PORT.key, 
serverPort.toString)) {
+    withSparkEnvConfs(
+      (Seq(
+        (Connect.CONNECT_GRPC_BINDING_PORT.key, serverPort.toString)) ++ 
extraServerConfs): _*) {
       SparkConnectService.start(spark.sparkContext)
     }
   }
diff --git 
a/sql/connect/server/src/test/scala/org/apache/spark/sql/connect/service/SparkConnectServiceKeepAliveSuite.scala
 
b/sql/connect/server/src/test/scala/org/apache/spark/sql/connect/service/SparkConnectServiceKeepAliveSuite.scala
new file mode 100644
index 000000000000..d1d273016e76
--- /dev/null
+++ 
b/sql/connect/server/src/test/scala/org/apache/spark/sql/connect/service/SparkConnectServiceKeepAliveSuite.scala
@@ -0,0 +1,412 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.spark.sql.connect.service
+
+import java.util.UUID
+import java.util.concurrent.TimeUnit
+
+import scala.collection.mutable
+import scala.concurrent.duration.FiniteDuration
+
+import org.scalatest.concurrent.Eventually
+
+import org.apache.spark.SparkException
+import org.apache.spark.sql.connect.SparkConnectServerTest
+import org.apache.spark.sql.connect.client.{RetryPolicy, SparkConnectClient}
+import org.apache.spark.sql.connect.config.Connect
+
+/**
+ * End-to-end test that the *real* `SparkConnectService.startGRPCService()` 
wiring actually
+ * applies the configured gRPC keepalive settings (SPARK-58094).
+ *
+ * Unlike the tests in `SparkConnectClientSuite` (sql/connect/client/jvm), 
which hand-build their
+ * own `NettyServerBuilder` that only mirrors the production server 
construction, this suite
+ * starts the real `SparkConnectService` (via `SparkConnectServerTest`) with 
the keepalive confs
+ * tuned to short test values, puts a freezable TCP relay in front of it, and 
confirms a blocked
+ * client call fails with a keepalive-triggered `UNAVAILABLE` within a bounded 
time -- so a future
+ * refactor of `startGRPCService()` that silently drops the keepalive wiring 
would be caught here,
+ * not just in the client-side unit tests.
+ */
+class SparkConnectServiceKeepAliveSuite extends SparkConnectServerTest {
+
+  // CONNECT_GRPC_KEEPALIVE_TIME/TIMEOUT are read via 
timeConf(TimeUnit.SECONDS), so anything
+  // sub-second (e.g. "300ms") truncates to 0 whole seconds and gRPC rejects 
it -- use the
+  // smallest whole-second value instead.
+  private val keepAliveMs = 1000L
+
+  override protected def extraServerConfs: Seq[(String, String)] = Seq(
+    Connect.CONNECT_GRPC_KEEPALIVE_ENABLED.key -> "true",
+    Connect.CONNECT_GRPC_KEEPALIVE_TIME.key -> "1s",
+    Connect.CONNECT_GRPC_KEEPALIVE_TIMEOUT.key -> "1s")
+
+  test("SPARK-58094: real SparkConnectService applies configured keepalive 
end-to-end") {
+    val serverSession =
+      SparkConnectService
+        .getOrCreateIsolatedSession(defaultUserId, defaultSessionId, None)
+        .session
+    serverSession.udf.register(
+      "sleep",
+      (ms: Int) => {
+        Thread.sleep(ms.toLong)
+        ms
+      })
+
+    val relay = new FreezableTcpRelay(serverPort)
+    try {
+      val client = SparkConnectClient
+        .builder()
+        .port(relay.port)
+        .sessionId(defaultSessionId)
+        .userId(defaultUserId)
+        .enableReattachableExecute()
+        .grpcKeepAliveEnabled(true)
+        .grpcKeepAliveTimeMs(keepAliveMs)
+        .grpcKeepAliveTimeoutMs(keepAliveMs)
+        .retryPolicy(RetryPolicy(maxRetries = Some(0), canRetry = _ => false, 
name = "NoRetry"))
+        .build()
+      try {
+        val operationId = UUID.randomUUID().toString
+        val iter = client.execute(buildPlan("select sleep(30000) as s"), 
Some(operationId))
+
+        // Wait for the query to genuinely start server-side before freezing, 
mirroring the
+        // manual repro's timing lesson: freezing too early (before the 
connection is actually
+        // established/in-flight) doesn't exercise the "dies while blocked" 
scenario.
+        Eventually.eventually(timeout(eventuallyTimeout)) {
+          
assert(SparkConnectService.executionManager.listExecuteHolders.length == 1)
+        }
+        relay.freeze()
+
+        // scalastyle:off awaitresult
+        val ex = intercept[SparkException] {
+          scala.concurrent.Await.result(
+            scala.concurrent.Future {
+              while (iter.hasNext) iter.next()
+            }(scala.concurrent.ExecutionContext.global),
+            FiniteDuration(15, TimeUnit.SECONDS))
+        }
+        // scalastyle:on awaitresult
+        // A keepalive-triggered UNAVAILABLE carries no wrapped cause (same as
+        // DEADLINE_EXCEEDED, see GrpcExceptionConverter.toThrowable), so the 
status
+        // code/description is only in the message.
+        assert(ex.getMessage.contains("UNAVAILABLE"))
+        assert(ex.getMessage.contains("Keepalive failed"))
+      } finally {
+        client.shutdown()
+      }
+    } finally {
+      relay.close()
+    }
+  }
+
+  test(
+    "SPARK-58094: keepalive does not disrupt a healthy long-lived connection 
through the " +
+      "real server") {
+    // Exercises the scenario flagged in review: a client whose ping cadence 
(11s) is faster
+    // than the server's own keepAlive.time (20s), through the real 
startGRPCService() wiring.
+    // With permitKeepAliveTime derived from the server's own keepAlive.time 
(the pre-review
+    // behavior), this client's cadence would violate that coupled invariant; 
with today's fixed,
+    // decoupled GRPC_KEEPALIVE_PERMIT_TIME_SECONDS floor (10s), it's 
comfortably tolerated.
+    val clientKeepAliveMs = 
(SparkConnectService.GRPC_KEEPALIVE_PERMIT_TIME_SECONDS + 1) * 1000
+    SparkConnectService.stop()
+    withSparkEnvConfs(
+      Connect.CONNECT_GRPC_BINDING_PORT.key -> serverPort.toString,
+      Connect.CONNECT_GRPC_KEEPALIVE_ENABLED.key -> "true",
+      Connect.CONNECT_GRPC_KEEPALIVE_TIME.key -> "20s",
+      Connect.CONNECT_GRPC_KEEPALIVE_TIMEOUT.key -> "5s") {
+      SparkConnectService.start(spark.sparkContext)
+    }
+    try {
+      val client = SparkConnectClient
+        .builder()
+        .port(serverPort)
+        .sessionId(defaultSessionId)
+        .userId(defaultUserId)
+        .grpcKeepAliveEnabled(true)
+        .grpcKeepAliveTimeMs(clientKeepAliveMs)
+        .grpcKeepAliveTimeoutMs(clientKeepAliveMs)
+        .retryPolicy(RetryPolicy(maxRetries = Some(0), canRetry = _ => false, 
name = "NoRetry"))
+        .build()
+      try {
+        // Several real calls spaced out beyond the client's keepalive 
interval, so the client's
+        // own periodic PINGs land against the real server wiring: if 
permitKeepAliveTime were
+        // ever re-derived from the server's own keepAlive.time (reintroducing 
the coupling
+        // flagged in review) or its wiring silently dropped, this is the 
exact shape of client
+        // that would be affected.
+        for (i <- 1 to 2) {
+          val operationId = UUID.randomUUID().toString
+          val iter = client.execute(buildPlan("select 1 as s"), 
Some(operationId))
+          while (iter.hasNext) iter.next()
+          Thread.sleep(clientKeepAliveMs + 2000)
+        }
+      } finally {
+        client.shutdown()
+      }
+    } finally {
+      SparkConnectService.stop()
+      withSparkEnvConfs(
+        Connect.CONNECT_GRPC_BINDING_PORT.key -> serverPort.toString,
+        Connect.CONNECT_GRPC_KEEPALIVE_ENABLED.key -> "true",
+        Connect.CONNECT_GRPC_KEEPALIVE_TIME.key -> "1s",
+        Connect.CONNECT_GRPC_KEEPALIVE_TIMEOUT.key -> "1s") {
+        SparkConnectService.start(spark.sparkContext)
+      }
+    }
+  }
+
+  test(
+    "SPARK-58094: server tolerates a default-on client's PINGs even with its 
own keepalive " +
+      "detection disabled") {
+    // Exercises the scenario flagged in review: 
permitKeepAliveTime/permitKeepAliveWithoutCalls
+    // used to be set only inside `if (CONNECT_GRPC_KEEPALIVE_ENABLED)`, so 
disabling only the
+    // server's own dead-client detection reverted permit-tolerance to 
grpc-netty's defaults
+    // (permitKeepAliveTime=5min, permitKeepAliveWithoutCalls=false). With no 
outstanding calls,
+    // grpc's KeepAliveEnforcer then requires 2 hours (its 
IMPLICIT_PERMIT_TIME_NANOS grace
+    // period for permitWithoutCalls=false) between "acceptable" pings instead 
of the configured
+    // permit floor, so a still-default-on client's periodic without-calls 
PINGs would each
+    // strike, and the 3rd strike 
(io.grpc.internal.KeepAliveEnforcer.MAX_PING_STRIKES=2)
+    // forcefully closes the connection (ForcefulCloseCommand) as 
too_many_pings -- even though
+    // nothing was ever actually wrong with the connection. With the permit 
calls hoisted out of
+    // the `if`, the enforcer instead uses the fixed 10s permit floor 
regardless of
+    // CONNECT_GRPC_KEEPALIVE_ENABLED, comfortably tolerating an 11s client 
ping interval, so no
+    // strikes accrue and the connection is never closed.
+    //
+    // A test loop that issues real RPC calls between pings cannot observe 
this: every server
+    // write (response headers/data) resets the enforcer's strike counter
+    // (NettyServerHandler$WriteMonitoringFrameWriter), so pings are only ever 
checked against a
+    // truly idle connection -- hence one call to establish the transport, 
then real idle time
+    // (no further calls) spanning several ping intervals, then a 
connection-count check.
+    val clientKeepAliveMs = 
(SparkConnectService.GRPC_KEEPALIVE_PERMIT_TIME_SECONDS + 1) * 1000
+    SparkConnectService.stop()
+    withSparkEnvConfs(
+      Connect.CONNECT_GRPC_BINDING_PORT.key -> serverPort.toString,
+      Connect.CONNECT_GRPC_KEEPALIVE_ENABLED.key -> "false",
+      Connect.CONNECT_GRPC_KEEPALIVE_TIME.key -> "1s",
+      Connect.CONNECT_GRPC_KEEPALIVE_TIMEOUT.key -> "1s") {
+      SparkConnectService.start(spark.sparkContext)
+    }
+    val relay = new FreezableTcpRelay(serverPort)
+    try {
+      val client = SparkConnectClient
+        .builder()
+        .port(relay.port)
+        .sessionId(defaultSessionId)
+        .userId(defaultUserId)
+        .grpcKeepAliveEnabled(true)
+        .grpcKeepAliveTimeMs(clientKeepAliveMs)
+        .grpcKeepAliveTimeoutMs(clientKeepAliveMs)
+        .retryPolicy(RetryPolicy(maxRetries = Some(0), canRetry = _ => false, 
name = "NoRetry"))
+        .build()
+      try {
+        def runOneCall(): Unit = {
+          val operationId = UUID.randomUUID().toString
+          val iter = client.execute(buildPlan("select 1 as s"), 
Some(operationId))
+          while (iter.hasNext) iter.next()
+        }
+
+        // Establishes the transport; this write resets the enforcer's strike 
counter, so the
+        // idle window below starts clean.
+        runOneCall()
+        val connectionsAfterFirstCall = relay.connectionCount
+
+        // Stay genuinely idle (no calls) for several client ping intervals, 
so at least 3
+        // without-calls PINGs land on the server with nothing resetting the 
strike counter in
+        // between -- enough to trip too_many_pings if the permit calls were 
re-gated.
+        Thread.sleep(clientKeepAliveMs * 4)
+
+        runOneCall()
+        assert(
+          relay.connectionCount == connectionsAfterFirstCall,
+          "server forcefully closed the connection under a default-on client's 
idle keepalive " +
+            "PINGs (too_many_pings) even though its own keepAlive.enabled was 
false -- the " +
+            "client had to open a new TCP connection to complete the second 
call")
+      } finally {
+        client.shutdown()
+      }
+    } finally {
+      relay.close()
+      SparkConnectService.stop()
+      withSparkEnvConfs(
+        Connect.CONNECT_GRPC_BINDING_PORT.key -> serverPort.toString,
+        Connect.CONNECT_GRPC_KEEPALIVE_ENABLED.key -> "true",
+        Connect.CONNECT_GRPC_KEEPALIVE_TIME.key -> "1s",
+        Connect.CONNECT_GRPC_KEEPALIVE_TIMEOUT.key -> "1s") {
+        SparkConnectService.start(spark.sparkContext)
+      }
+    }
+  }
+
+  test(
+    "SPARK-58094: disabling spark.connect.grpc.keepAlive.enabled reverts to 
the pre-fix hang") {
+    // Restart the real service with keepalive fully disabled (not just given 
short/aggressive
+    // timing) to prove the flag genuinely gates the fix rather than only 
tuning its timing.
+    SparkConnectService.stop()
+    withSparkEnvConfs(
+      Connect.CONNECT_GRPC_BINDING_PORT.key -> serverPort.toString,
+      Connect.CONNECT_GRPC_KEEPALIVE_ENABLED.key -> "false",
+      Connect.CONNECT_GRPC_KEEPALIVE_TIME.key -> "1s",
+      Connect.CONNECT_GRPC_KEEPALIVE_TIMEOUT.key -> "1s") {
+      SparkConnectService.start(spark.sparkContext)
+    }
+    try {
+      val serverSession =
+        SparkConnectService
+          .getOrCreateIsolatedSession(defaultUserId, defaultSessionId, None)
+          .session
+      serverSession.udf.register(
+        "sleep",
+        (ms: Int) => {
+          Thread.sleep(ms.toLong)
+          ms
+        })
+
+      val relay = new FreezableTcpRelay(serverPort)
+      try {
+        val client = SparkConnectClient
+          .builder()
+          .port(relay.port)
+          .sessionId(defaultSessionId)
+          .userId(defaultUserId)
+          .enableReattachableExecute()
+          .grpcKeepAliveEnabled(false)
+          .grpcKeepAliveTimeMs(keepAliveMs)
+          .grpcKeepAliveTimeoutMs(keepAliveMs)
+          .retryPolicy(RetryPolicy(maxRetries = Some(0), canRetry = _ => 
false, name = "NoRetry"))
+          .build()
+        try {
+          val operationId = UUID.randomUUID().toString
+          val iter = client.execute(buildPlan("select sleep(30000) as s"), 
Some(operationId))
+
+          Eventually.eventually(timeout(eventuallyTimeout)) {
+            
assert(SparkConnectService.executionManager.listExecuteHolders.length == 1)
+          }
+          relay.freeze()
+
+          // With keepalive disabled on both sides, the call must NOT resolve 
within a window
+          // well past the 1s/1s that would have triggered detection if it 
were enabled --
+          // confirming the flag genuinely disables the fix, matching the 
original bug's
+          // "blocks forever, no exception" symptom, rather than the call just 
being slow.
+          // scalastyle:off awaitresult
+          val stillBlocked =
+            try {
+              scala.concurrent.Await.result(
+                scala.concurrent.Future {
+                  while (iter.hasNext) iter.next()
+                }(scala.concurrent.ExecutionContext.global),
+                FiniteDuration(5, TimeUnit.SECONDS))
+              false
+            } catch {
+              case _: java.util.concurrent.TimeoutException => true
+            }
+          // scalastyle:on awaitresult
+          assert(stillBlocked, "expected the call to remain hung with 
keepalive disabled")
+        } finally {
+          client.shutdown()
+        }
+      } finally {
+        relay.close()
+      }
+    } finally {
+      // Restore the enabled server for afterAll()/subsequent tests in this 
suite.
+      SparkConnectService.stop()
+      withSparkEnvConfs(
+        Connect.CONNECT_GRPC_BINDING_PORT.key -> serverPort.toString,
+        Connect.CONNECT_GRPC_KEEPALIVE_ENABLED.key -> "true",
+        Connect.CONNECT_GRPC_KEEPALIVE_TIME.key -> "1s",
+        Connect.CONNECT_GRPC_KEEPALIVE_TIMEOUT.key -> "1s") {
+        SparkConnectService.start(spark.sparkContext)
+      }
+    }
+  }
+}
+
+/**
+ * A minimal bidirectional TCP relay that can be "frozen" mid-connection 
without closing either
+ * side's socket -- simulating a NAT gateway/load balancer/corporate proxy 
that silently drops an
+ * idle connection's mapping (no TCP RST/FIN sent to either endpoint). Mirrors 
the equivalent
+ * helper in `SparkConnectClientSuite` (sql/connect/client/jvm); duplicated 
here rather than
+ * shared across modules since it's a small, self-contained test utility.
+ */
+private class FreezableTcpRelay(targetPort: Int) {
+  private val listener = new java.net.ServerSocket(0)
+  @volatile private var frozen = false
+  private val sockets = mutable.ListBuffer[java.net.Socket]()
+  private val acceptedConnections = new 
java.util.concurrent.atomic.AtomicInteger(0)
+
+  private val acceptThread = new Thread(() => {
+    try {
+      while (true) {
+        val clientSocket = listener.accept()
+        acceptedConnections.incrementAndGet()
+        val serverSocket = new java.net.Socket("127.0.0.1", targetPort)
+        sockets.synchronized {
+          sockets += clientSocket
+          sockets += serverSocket
+        }
+        pump(clientSocket, serverSocket)
+        pump(serverSocket, clientSocket)
+      }
+    } catch {
+      case _: java.io.IOException => // listener closed, relay shutting down
+    }
+  })
+  acceptThread.setDaemon(true)
+  acceptThread.start()
+
+  def port: Int = listener.getLocalPort
+
+  // Number of distinct client TCP connections accepted so far -- useful to 
detect a
+  // client-transparent reconnect (e.g. after the server forcefully closes the 
connection)
+  // that wouldn't otherwise surface as a visible failure to the application.
+  def connectionCount: Int = acceptedConnections.get()
+
+  // Once frozen, bytes read from either socket are dropped rather than 
forwarded: no data
+  // (including keepalive PING/PONG frames) reaches the other side, but 
neither socket is
+  // closed -- the same observable behavior as a middlebox silently 
black-holing the connection.
+  def freeze(): Unit = frozen = true
+
+  private def pump(src: java.net.Socket, dst: java.net.Socket): Unit = {
+    val t = new Thread(() => {
+      try {
+        val in = src.getInputStream
+        val out = dst.getOutputStream
+        val buf = new Array[Byte](8192)
+        var n = 0
+        while ({ n = in.read(buf); n != -1 }) {
+          if (!frozen) {
+            out.write(buf, 0, n)
+            out.flush()
+          }
+        }
+      } catch {
+        case _: java.io.IOException => // socket closed, nothing left to pump
+      }
+    })
+    t.setDaemon(true)
+    t.start()
+  }
+
+  def close(): Unit = {
+    listener.close()
+    sockets.synchronized(sockets.foreach { s =>
+      try {
+        s.close()
+      } catch {
+        case _: java.io.IOException =>
+      }
+    })
+  }
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to