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

Cole-Greer pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/tinkerpop.git


The following commit(s) were added to refs/heads/master by this push:
     new cfffb3698e Aligned JS and .NET readTimeoutMillis with other GLVs 
(#3524)
cfffb3698e is described below

commit cfffb3698e3c42be5df1c43eb42f4e6948a91a0c
Author: Guian Gumpac <[email protected]>
AuthorDate: Sat Jul 18 18:08:38 2026 -0700

    Aligned JS and .NET readTimeoutMillis with other GLVs (#3524)
    
    Brings the JavaScript and .NET drivers in line with Java, Python, and Go so 
that `readTimeout` bounds the wait for the initial server response, not just 
the response body. Previously a server that accepted the connection but never 
responded was not bounded by `readTimeout` in JS or .NET, falling through to a 
fixed framework default instead.
    
    Assisted-by: Kiro: Claude Opus 4.8
---
 docs/src/reference/gremlin-variants.asciidoc       | 31 ++++++++--------
 docs/src/upgrade/release-4.x.x.asciidoc            |  5 +--
 .../src/Gremlin.Net/Driver/Connection.cs           | 42 ++++++++++++++++++++--
 .../src/Gremlin.Net/Driver/ConnectionSettings.cs   | 13 ++++---
 .../Driver/ClientBehaviorIntegrationTests.cs       | 25 +++++++++++++
 .../Gremlin.Net.UnitTest/Driver/ConnectionTests.cs | 33 ++++++++++++++++-
 .../gremlin-javascript/lib/driver/connection.ts    |  2 +-
 .../gremlin-javascript/lib/driver/dispatcher.ts    |  3 +-
 .../test/integration/client-behavior-tests.js      | 23 ++++++++++--
 .../test/unit/dispatcher-test.js                   | 11 +++++-
 10 files changed, 158 insertions(+), 30 deletions(-)

diff --git a/docs/src/reference/gremlin-variants.asciidoc 
b/docs/src/reference/gremlin-variants.asciidoc
index 3bc95a6fb7..c18d736f20 100644
--- a/docs/src/reference/gremlin-variants.asciidoc
+++ b/docs/src/reference/gremlin-variants.asciidoc
@@ -289,7 +289,7 @@ can be passed to the `NewClient` or 
`NewDriverRemoteConnection` functions as con
 |MaxIdleConnections | Maximum number of idle (keep-alive) connections in the 
pool. |8
 |IdleTimeoutMillis | How long in milliseconds idle connections remain in the 
pool before being closed. Also settable as `IdleTimeout` (a `time.Duration`); 
set only one. |180000
 |KeepAliveTimeMillis | Idle time in milliseconds before TCP keep-alive probes 
begin. Also settable as `KeepAliveTime` (a `time.Duration`); set only one. 
|30000
-|ReadTimeoutMillis | Idle-read timeout in milliseconds reset on each read of 
the response body. Set to `0` to disable. Also settable as `ReadTimeout` (a 
`time.Duration`); set only one. |0
+|ReadTimeoutMillis | Idle-read timeout in milliseconds. Bounds the wait for 
the initial server response and the idle time between response chunks, 
resetting per chunk. It is not a whole-request deadline. Set to `0` to disable. 
Also settable as `ReadTimeout` (a `time.Duration`); set only one. |0
 |Compression |The wire compression negotiated with the server 
(`gremlingo.CompressionNone` or `gremlingo.CompressionDeflate`). 
|gremlingo.CompressionDeflate
 |BatchSize |The connection-level default batch size used when a request does 
not specify one. |64
 |BulkResults |Connection-level default for `bulkResults`, applied to every 
request unless overridden per-request. The `DriverRemoteConnection` traversal 
path defaults to `true` regardless of this setting. |false
@@ -307,8 +307,8 @@ Serialization is not configurable in Gremlin-Go. Requests 
are always serialized
 deserialized as GraphBinary. A request body encoding other than JSON can only 
be produced through a
 <<gremlin-go-interceptors,request interceptor>>.
 
-Note that no driver timeout bounds the *total* duration of a request once it 
is under way. `ReadTimeout` only bounds
-the gap between response chunks, so a response that keeps producing chunks 
will not time out no matter how long it
+Note that no driver timeout bounds the *total* duration of a request once it 
is under way. `ReadTimeout` bounds the
+wait for the initial server response and the gap between response chunks, so a 
response that keeps producing chunks will not time out no matter how long it
 runs overall, and there is no client-side "overall" request timeout. If you 
need an absolute deadline, impose it in
 your application around the call. Because `Submit` does not accept a 
`context.Context`, run it in a goroutine and
 bound it with a `select`:
@@ -1105,7 +1105,7 @@ The following table describes the various configuration 
options for the Gremlin
 |connectionPool.maxWaitForClose |The amount of time in milliseconds to wait 
for pending messages to be returned from the server before closing the 
connection. |3000
 |connectionPool.reconnectInterval |The amount of time in milliseconds to wait 
before trying to reconnect to a dead host. |1000
 |connectionPool.batchSize |The default value for the per-request batch size 
used when a request does not specify one. |64
-|connectionPool.readTimeoutMillis |Idle-read timeout in milliseconds that 
bounds the time between inbound response chunks. Set to `0` to disable. On 
`Cluster.Builder` also settable as `readTimeout(Duration)`. |0
+|connectionPool.readTimeoutMillis |Idle-read timeout in milliseconds. Bounds 
the wait for the initial server response and the idle time between response 
chunks, resetting per chunk. It is not a whole-request deadline. Set to `0` to 
disable. On `Cluster.Builder` also settable as `readTimeout(Duration)`. |0
 |connectionPool.sslCipherSuites |The list of JSSE ciphers to support for SSL 
connections. If specified, only the ciphers that are listed and supported will 
be enabled. If not specified, the JVM default is used.  |_none_
 |connectionPool.sslEnabledProtocols |The list of SSL protocols to support for 
SSL connections. If specified, only the protocols that are listed and supported 
will be enabled. If not specified, the JVM default is used.  |_none_
 |connectionPool.sslSkipCertValidation |Configures the `TrustManager` to trust 
all certs without any validation. Should not be used in production.|false
@@ -1622,8 +1622,8 @@ Responses are now streamed rather than buffered to a 
fixed size, so this client-
 responses are causing timeouts, increase the `readTimeout` on the driver and 
consider applying server-side limits
 (such as result iteration or serialization limits) to constrain the size of 
responses being returned.
 
-Note that no driver timeout bounds the *total* duration of a request once it 
is under way. `readTimeout` only bounds
-the gap between response chunks, so a response that keeps producing chunks 
will not time out no matter how long it
+Note that no driver timeout bounds the *total* duration of a request once it 
is under way. `readTimeout` bounds the
+wait for the initial server response and the gap between response chunks, so a 
response that keeps producing chunks will not time out no matter how long it
 runs overall, and there is no client-side "overall" request timeout. If you 
need an absolute deadline, impose it in
 your application around the asynchronous API, for example by wrapping a 
`CompletableFuture` with a timeout:
 
@@ -2022,7 +2022,7 @@ can be passed in the constructor of a new `Client` or 
`DriverRemoteConnection` :
 |options |Object |The connection options. |{}
 |options.traversalSource |String |The name of the remote 
`GraphTraversalSource`. |'g'
 |options.maxConnections |Number |Caps the number of concurrent connections per 
origin on the default dispatcher. |128
-|options.readTimeoutMillis |Number |A per-read idle timeout in milliseconds 
(undici `bodyTimeout`). It resets per chunk, so it is safe for streaming. 
|runtime default
+|options.readTimeoutMillis |Number |Idle-read timeout in milliseconds. Bounds 
the wait for the initial server response and the idle time between response 
chunks, resetting per chunk. It is not a whole-request deadline. Maps to undici 
`headersTimeout` and `bodyTimeout`. |runtime default
 |options.maxResponseHeaderBytes |Number |The maximum size of the response 
headers in bytes (undici `maxHeaderSize`). |runtime default
 |options.keepAliveTimeMillis |Number |Idle time in milliseconds before TCP 
keep-alive probes begin. Enables `SO_KEEPALIVE` on the socket. Set to `0` to 
disable. |30000
 |options.proxy |String |An HTTP proxy URI. When set, requests are routed 
through an undici `ProxyAgent`. |undefined
@@ -2042,8 +2042,8 @@ TLS is configured through the Node.js/undici runtime (for 
example the `NODE_EXTR
 `NODE_TLS_REJECT_UNAUTHORIZED` environment variables), not through driver 
options. Custom headers are set via an
 interceptor rather than a `headers` option, for example `interceptors: (req) 
=> { req.headers['X-Custom'] = 'value'; }`.
 
-Note that no driver timeout bounds the *total* duration of a request once it 
is under way. `readTimeoutMillis` only bounds
-the gap between response chunks, so a response that keeps producing chunks 
will not time out no matter how long it
+Note that no driver timeout bounds the *total* duration of a request once it 
is under way. `readTimeoutMillis` bounds the
+wait for the initial server response and the gap between response chunks, so a 
response that keeps producing chunks will not time out no matter how long it
 runs overall, and there is no client-side "overall" request timeout. If you 
need an absolute deadline, impose it in
 your application around the call by racing the `submit` promise against a 
timeout:
 
@@ -2738,7 +2738,7 @@ constructor:
 |BatchSize |The connection-level default batch size used to fill the 
per-request batch size when it is unset. |64
 |Ssl |The `SslClientAuthenticationOptions` used for HTTPS connections (client 
certificates, custom CA, protocols, etc.). `SkipCertificateValidation` is 
applied to an internal copy of these options rather than mutating the object 
you provide. |`null`
 |MaxResponseHeaderBytes |The maximum allowed size, in bytes, of the response 
headers. `0` leaves the handler default unchanged (converted internally to the 
handler's native kilobyte unit). |0
-|ReadTimeoutMillis |The idle-read timeout in milliseconds applied to each 
individual read of the response stream. It resets per chunk. `0` (the default) 
disables it. Also settable as `ReadTimeout` (a `TimeSpan`; 
`Timeout.InfiniteTimeSpan` disables). |0
+|ReadTimeoutMillis |Idle-read timeout in milliseconds. Bounds the wait for the 
initial server response and the idle time between response chunks, resetting 
per chunk. It is not a whole-request deadline. `0` (the default) disables it. 
Also settable as `ReadTimeout` (a `TimeSpan`; `Timeout.InfiniteTimeSpan` 
disables). |0
 |Proxy |The `IWebProxy` used for connections. |`null`
 |EnableUserAgentOnConnect |Enables sending a user agent to the server on 
requests.
 More details can be found in provider docs
@@ -2747,8 +2747,9 @@ 
link:https://tinkerpop.apache.org/docs/x.y.z/dev/provider/#_graph_driver_provide
 |SkipCertificateValidation |Whether to skip SSL certificate validation. Only 
use for testing with self-signed certificates. When `Ssl` is also provided, the 
accept-all callback is set on an internal copy so the supplied options object 
is never mutated. |false
 |=========================================================
 
-Note that no driver timeout bounds the *total* duration of a request once it 
is under way. `ReadTimeout` only bounds
-the gap between response chunks, so a response that keeps producing chunks 
will not time out no matter how long it
+Note that no driver timeout bounds the *total* duration of a request once it 
is under way. `ReadTimeout` bounds
+the wait for the initial server response and the gap between response chunks, 
so a response that keeps producing
+chunks will not time out no matter how long it
 runs overall, and there is no client-side "overall" request timeout. If you 
need an absolute deadline, impose it in
 your application by passing a cancellation token that cancels after the 
deadline:
 
@@ -3450,7 +3451,7 @@ can be passed to the `Client` or `DriverRemoteConnection` 
instance as keyword ar
 |auth |An authentication interceptor. Always appended to the end of the 
interceptor list so it runs last. |`None`
 |max_connections |The maximum number of connections used by the pool. |128
 |connect_timeout_millis |Timeout in milliseconds for establishing the 
connection (TCP connect plus TLS handshake). Also settable as `connect_timeout` 
(in seconds). |5000
-|read_timeout_millis |Per-read idle timeout in milliseconds applied while 
streaming a response. Resets per chunk. Also settable as `read_timeout` (in 
seconds). |`None`
+|read_timeout_millis |Idle-read timeout in milliseconds. Bounds the wait for 
the initial server response and the idle time between response chunks, 
resetting per chunk. It is not a whole-request deadline. Maps to aiohttp 
`sock_read`. Also settable as `read_timeout` (in seconds). |`None`
 |write_timeout |Timeout in seconds for writing a request to the transport. 
|`None`
 |ssl |An `ssl.SSLContext` used for TLS connections. |`None`
 |idle_timeout_millis |How long in milliseconds an idle connection remains in 
the pool before being closed. Also settable as `idle_timeout` (in seconds). 
|180000
@@ -3479,8 +3480,8 @@ g = traversal().with_(
                          read_timeout=30))
 ----
 
-Note that no driver timeout bounds the *total* duration of a request once it 
is under way. `read_timeout` only bounds
-the gap between response chunks, so a response that keeps producing chunks 
will not time out no matter how long it
+Note that no driver timeout bounds the *total* duration of a request once it 
is under way. `read_timeout` bounds the
+wait for the initial server response and the gap between response chunks, so a 
response that keeps producing chunks will not time out no matter how long it
 runs overall, and there is no client-side "overall" request timeout. If you 
need an absolute deadline, impose it in
 your application. `submit_async()` and `ResultSet.all()` return 
`concurrent.futures.Future` objects, so pass a
 `timeout` to `result()`:
diff --git a/docs/src/upgrade/release-4.x.x.asciidoc 
b/docs/src/upgrade/release-4.x.x.asciidoc
index bd9e9f02b1..129e66ec2b 100644
--- a/docs/src/upgrade/release-4.x.x.asciidoc
+++ b/docs/src/upgrade/release-4.x.x.asciidoc
@@ -392,8 +392,9 @@ These change runtime behavior on upgrade even if you do not 
change your configur
   `CompressionNone`).
 - *Connect timeout lowered to 5s* (from 15s, where applicable) and is now 
actually applied to transport establishment
   (TCP connect plus TLS handshake), not the whole request.
-- *`readTimeout` is an idle-read timeout*, reset on each inbound response 
chunk, so it is streaming-safe and bounds only
-  the gap between chunks, not total response duration. It is off by default.
+- *`readTimeout` is an idle-read timeout*, armed when the request is sent and 
reset on each inbound response chunk, so it
+  is streaming-safe and bounds the wait for the first response as well as the 
idle gap between chunks, but not total
+  response duration. It is off by default.
 - *`idleTimeout` reaps only pooled connections* that are idle between 
requests; it no longer bounds an in-flight
   response (that is `readTimeout`'s job).
 
diff --git a/gremlin-dotnet/src/Gremlin.Net/Driver/Connection.cs 
b/gremlin-dotnet/src/Gremlin.Net/Driver/Connection.cs
index 7e16bffea9..13de107654 100644
--- a/gremlin-dotnet/src/Gremlin.Net/Driver/Connection.cs
+++ b/gremlin-dotnet/src/Gremlin.Net/Driver/Connection.cs
@@ -303,8 +303,7 @@ namespace Gremlin.Net.Driver
                     }
                 }
 
-                response = await _httpClient.SendAsync(httpRequest,
-                    HttpCompletionOption.ResponseHeadersRead, 
cancellationToken)
+                response = await SendWithReadTimeoutAsync(httpRequest, 
cancellationToken)
                     .ConfigureAwait(false);
             }
 
@@ -414,6 +413,45 @@ namespace Gremlin.Net.Driver
             }
         }
 
+        /// <summary>
+        ///     Sends the request and waits for the response headers, bounding 
that wait by
+        ///     <see cref="ConnectionSettings.ReadTimeout"/> when it is 
positive. This uses the same
+        ///     disambiguation idiom as <see cref="ReadTimeoutStream"/>: a 
timeout
+        ///     <see cref="CancellationTokenSource"/> linked with the caller 
token, armed with
+        ///     <see cref="CancellationTokenSource.CancelAfter(TimeSpan)"/>, 
so a fired timeout
+        ///     (when the caller token did not fire) surfaces as a <see 
cref="TimeoutException"/>.
+        ///     When <see cref="ConnectionSettings.ReadTimeout"/> is 
non-positive the caller token is
+        ///     passed straight through with no wrapping. The timeout CTS is 
disposed once headers are
+        ///     read so its timer does not linger into body streaming.
+        /// </summary>
+        private async Task<HttpResponseMessage> 
SendWithReadTimeoutAsync(HttpRequestMessage httpRequest,
+            CancellationToken cancellationToken)
+        {
+            if (_settings.ReadTimeout <= TimeSpan.Zero)
+            {
+                return await _httpClient.SendAsync(httpRequest,
+                    HttpCompletionOption.ResponseHeadersRead, 
cancellationToken)
+                    .ConfigureAwait(false);
+            }
+
+            using var timeoutCts = new CancellationTokenSource();
+            using var linkedCts =
+                
CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, 
timeoutCts.Token);
+            timeoutCts.CancelAfter(_settings.ReadTimeout);
+            try
+            {
+                return await _httpClient.SendAsync(httpRequest,
+                    HttpCompletionOption.ResponseHeadersRead, linkedCts.Token)
+                    .ConfigureAwait(false);
+            }
+            catch (OperationCanceledException) when 
(timeoutCts.IsCancellationRequested &&
+                                                     
!cancellationToken.IsCancellationRequested)
+            {
+                throw new TimeoutException(
+                    $"Timed out after 
{_settings.ReadTimeout.TotalSeconds:0.###}s waiting for the initial server 
response.");
+            }
+        }
+
         /// <summary>
         ///     Converts a maximum response header size expressed in bytes to 
the kilobyte unit
         ///     used by <see 
cref="SocketsHttpHandler.MaxResponseHeadersLength"/>, rounding up so
diff --git a/gremlin-dotnet/src/Gremlin.Net/Driver/ConnectionSettings.cs 
b/gremlin-dotnet/src/Gremlin.Net/Driver/ConnectionSettings.cs
index 5381acc055..c3f7e37600 100644
--- a/gremlin-dotnet/src/Gremlin.Net/Driver/ConnectionSettings.cs
+++ b/gremlin-dotnet/src/Gremlin.Net/Driver/ConnectionSettings.cs
@@ -160,10 +160,12 @@ namespace Gremlin.Net.Driver
         public int MaxResponseHeaderBytes { get; set; } = 0;
 
         /// <summary>
-        ///     Gets or sets the idle-read timeout applied to each individual 
read of the response
-        ///     stream. It resets per chunk, so it is an idle-read timeout 
rather than a
-        ///     whole-request deadline. <see 
cref="System.Threading.Timeout.InfiniteTimeSpan"/>
-        ///     (the default) disables it.
+        ///     Gets or sets the read timeout. It bounds two waits: the wait 
for the initial server
+        ///     response (time to first byte / response headers), as a single 
deadline armed when the
+        ///     request is sent; and, once the response body is streaming, the 
idle time before each
+        ///     individual read of the stream, reset per read. It is therefore 
not a whole-request
+        ///     deadline and does not bound the total streaming duration once 
the server has begun
+        ///     responding.
         /// </summary>
         public TimeSpan ReadTimeout { get; set; } = 
System.Threading.Timeout.InfiniteTimeSpan;
 
@@ -171,6 +173,9 @@ namespace Gremlin.Net.Driver
         ///     Gets or sets <see cref="ReadTimeout"/> in whole milliseconds, 
where <c>0</c> disables it
         ///     (mapping to <see 
cref="System.Threading.Timeout.InfiniteTimeSpan"/>). This is the millisecond
         ///     view of the same setting; <see cref="ReadTimeout"/> is the 
idiomatic <see cref="TimeSpan"/> form.
+        ///     It bounds the wait for the initial server response (time to 
first byte / response
+        ///     headers) as well as the idle time between response body 
chunks, but it is not a
+        ///     whole-request deadline.
         /// </summary>
         public int ReadTimeoutMillis
         {
diff --git 
a/gremlin-dotnet/test/Gremlin.Net.IntegrationTest/Driver/ClientBehaviorIntegrationTests.cs
 
b/gremlin-dotnet/test/Gremlin.Net.IntegrationTest/Driver/ClientBehaviorIntegrationTests.cs
index 03977423b2..dca18329ad 100644
--- 
a/gremlin-dotnet/test/Gremlin.Net.IntegrationTest/Driver/ClientBehaviorIntegrationTests.cs
+++ 
b/gremlin-dotnet/test/Gremlin.Net.IntegrationTest/Driver/ClientBehaviorIntegrationTests.cs
@@ -244,6 +244,31 @@ namespace Gremlin.Net.IntegrationTest.Driver
             Assert.Single(results);
         }
 
+        [Fact]
+        public async Task 
ShouldTimeoutWhenServerNeverRespondsUsingReadTimeout()
+        {
+            SkipIfServerUnavailable();
+
+            // A short ReadTimeout must bound the wait for the initial server 
response even
+            // when the caller supplies no cancellation token.
+            var settings = new ConnectionSettings { ReadTimeout = 
TimeSpan.FromSeconds(2) };
+            using var timeoutClient = new GremlinClient(
+                new GremlinServer(Host, SocketServerConstants.Port), 
connectionSettings: settings);
+
+            var ex = await Assert.ThrowsAsync<TimeoutException>(async () =>
+            {
+                var resultSet = await timeoutClient.SubmitAsync<dynamic>(
+                    SocketServerConstants.GremlinNoResponse);
+                await resultSet.ToListAsync();
+            });
+            Assert.Contains("waiting for the initial server response", 
ex.Message);
+
+            // Recovery with main client
+            var resultSet = await 
_client!.SubmitAsync<dynamic>(SocketServerConstants.GremlinSingleVertex);
+            var results = await resultSet.ToListAsync();
+            Assert.Single(results);
+        }
+
         [Fact]
         public async Task ShouldHandleAsyncRequestsDuringConnectionClose()
         {
diff --git a/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/ConnectionTests.cs 
b/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/ConnectionTests.cs
index 7a3bee9e62..a15e167809 100644
--- a/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/ConnectionTests.cs
+++ b/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/ConnectionTests.cs
@@ -1168,6 +1168,26 @@ namespace Gremlin.Net.UnitTest.Driver
             });
         }
 
+        [Fact]
+        public async Task 
ShouldTimeOutWhenServerNeverSendsResponseWhenReadTimeoutSet()
+        {
+            // A server that accepts the request but never sends a response 
should trigger the
+            // read timeout during the header wait, so the exception surfaces 
directly from
+            // SubmitAsync (before any results can be enumerated).
+            var handler = new NeverRespondingHandler();
+            var httpClient = new HttpClient(handler);
+            var serializer = CreateReadingSerializer();
+            var settings = new ConnectionSettings
+            {
+                ReadTimeout = TimeSpan.FromMilliseconds(100)
+            };
+            using var connection = new Connection(TestUri, serializer, 
settings, httpClient);
+
+            var ex = await Assert.ThrowsAsync<TimeoutException>(async () =>
+                await connection.SubmitAsync<object>(CreateTestRequest()));
+            Assert.Contains("waiting for the initial server response", 
ex.Message);
+        }
+
         private static IMessageSerializer CreateReadingSerializer(
             string mimeType = SerializationTokens.GraphBinary4MimeType)
         {
@@ -1311,8 +1331,19 @@ namespace Gremlin.Net.UnitTest.Driver
         }
 
         /// <summary>
-        ///     A stream wrapper that tracks read operations.
+        ///     A mock handler whose SendAsync never returns until cancelled, 
simulating a server
+        ///     that accepts the request but never sends a response. Used to 
exercise the read
+        ///     timeout during the initial header wait.
         /// </summary>
+        private class NeverRespondingHandler : HttpMessageHandler
+        {
+            protected override async Task<HttpResponseMessage> 
SendAsync(HttpRequestMessage request,
+                CancellationToken cancellationToken)
+            {
+                await Task.Delay(Timeout.Infinite, 
cancellationToken).ConfigureAwait(false);
+                return new HttpResponseMessage(HttpStatusCode.OK);
+            }
+        }
         private class TrackingStream : Stream
         {
             private readonly Stream _inner;
diff --git a/gremlin-js/gremlin-javascript/lib/driver/connection.ts 
b/gremlin-js/gremlin-javascript/lib/driver/connection.ts
index fc4e1c0bda..68f477d081 100644
--- a/gremlin-js/gremlin-javascript/lib/driver/connection.ts
+++ b/gremlin-js/gremlin-javascript/lib/driver/connection.ts
@@ -57,7 +57,7 @@ export type ConnectionOptions = {
   enableUserAgentOnConnect?: boolean;
   /** Maximum number of concurrent connections per origin. Defaults to 128. */
   maxConnections?: number;
-  /** Idle-read (body) timeout in milliseconds, applied to the default 
dispatcher. */
+  /** Read timeout in ms, applied to the default dispatcher. Maps to undici 
`headersTimeout` (wait for first response byte) and `bodyTimeout` (idle between 
body chunks). */
   readTimeoutMillis?: number;
   /** Maximum size of the response headers in bytes, applied to the default 
dispatcher. */
   maxResponseHeaderBytes?: number;
diff --git a/gremlin-js/gremlin-javascript/lib/driver/dispatcher.ts 
b/gremlin-js/gremlin-javascript/lib/driver/dispatcher.ts
index 7e323aff77..4954563888 100644
--- a/gremlin-js/gremlin-javascript/lib/driver/dispatcher.ts
+++ b/gremlin-js/gremlin-javascript/lib/driver/dispatcher.ts
@@ -29,7 +29,7 @@ export const DEFAULT_KEEP_ALIVE_TIME = 30000;
 export type DispatcherOptions = {
   /** Max concurrent connections per origin. Defaults to {@link 
DEFAULT_MAX_CONNECTIONS}. */
   maxConnections?: number;
-  /** Idle-read (body) timeout in ms. Maps to undici `bodyTimeout`. */
+  /** Read timeout in ms. Maps to undici `headersTimeout` (wait for first 
response byte) and `bodyTimeout` (idle between body chunks). */
   readTimeoutMillis?: number;
   /** Max response header size in bytes. Maps to undici `maxHeaderSize`. */
   maxResponseHeaderBytes?: number;
@@ -84,6 +84,7 @@ export function buildAgentOptions(options: DispatcherOptions 
= {}): Agent.Option
   // Connect/idle timeouts are intentionally left to undici defaults (the GLV 
spec marks the JS
   // connect/idle timeout as N/A), not exposed as driver options.
   if (options.readTimeoutMillis !== undefined) {
+    agentOptions.headersTimeout = options.readTimeoutMillis;
     agentOptions.bodyTimeout = options.readTimeoutMillis;
   }
   if (options.maxResponseHeaderBytes !== undefined) {
diff --git 
a/gremlin-js/gremlin-javascript/test/integration/client-behavior-tests.js 
b/gremlin-js/gremlin-javascript/test/integration/client-behavior-tests.js
index 98f6537aa0..43dfd27324 100644
--- a/gremlin-js/gremlin-javascript/test/integration/client-behavior-tests.js
+++ b/gremlin-js/gremlin-javascript/test/integration/client-behavior-tests.js
@@ -117,10 +117,27 @@ describe('Client Behavior', function () {
     assert.ok(result.length > 0);
   });
 
-  it.skip('should timeout when server never responds - JS driver lacks 
client-side idle timeout', async function () {
-    const shortTimeoutClient = createClient({ requestTimeout: 1000 });
+  it('should timeout when server never responds', async function () {
+    const shortTimeoutClient = createClient({ readTimeoutMillis: 1000 });
     try {
-      await assert.rejects(shortTimeoutClient.submit(GREMLIN_NO_RESPONSE));
+      // undici's headersTimeout must fire well before its 300s default. The 
submit path does not
+      // wrap the fetch error, so it surfaces as a WHATWG `TypeError('fetch 
failed')` whose `cause`
+      // is undici's HeadersTimeoutError (`code: 'UND_ERR_HEADERS_TIMEOUT'`). 
Assert both that the
+      // rejection is fast and that it is a timeout error, so the intent is 
explicit.
+      const start = Date.now();
+      await assert.rejects(shortTimeoutClient.submit(GREMLIN_NO_RESPONSE), 
(err) => {
+        const cause = err && err.cause;
+        const code = (cause && cause.code) || err.code;
+        const message = `${(cause && cause.message) || ''} ${err.message || 
''}`;
+        assert.ok(
+          code === 'UND_ERR_HEADERS_TIMEOUT' || /headers timeout|timeout|fetch 
failed/i.test(message),
+          `expected an undici timeout error, got: ${err.stack || err}`,
+        );
+        return true;
+      });
+      const elapsed = Date.now() - start;
+      assert.ok(elapsed < 10000, `expected timeout to fire quickly, but it 
took ${elapsed}ms`);
+
       const result = await shortTimeoutClient.submit(GREMLIN_SINGLE_VERTEX);
       assert.strictEqual(result.length, 1);
     } finally {
diff --git a/gremlin-js/gremlin-javascript/test/unit/dispatcher-test.js 
b/gremlin-js/gremlin-javascript/test/unit/dispatcher-test.js
index 8cfc4add0b..a01dd55be9 100644
--- a/gremlin-js/gremlin-javascript/test/unit/dispatcher-test.js
+++ b/gremlin-js/gremlin-javascript/test/unit/dispatcher-test.js
@@ -76,9 +76,16 @@ describe('dispatcher', function () {
   });
 
   describe('buildAgentOptions (undici option mapping)', function () {
-    it('maps readTimeoutMillis to the undici Agent bodyTimeout', function () {
+    it('maps readTimeoutMillis to the undici Agent bodyTimeout and 
headersTimeout', function () {
       const opts = buildAgentOptions({ readTimeoutMillis: 1234 });
       assert.strictEqual(opts.bodyTimeout, 1234);
+      assert.strictEqual(opts.headersTimeout, 1234);
+    });
+
+    it('maps readTimeoutMillis 0 to bodyTimeout and headersTimeout 0 (undici 
disables the timer)', function () {
+      const opts = buildAgentOptions({ readTimeoutMillis: 0 });
+      assert.strictEqual(opts.bodyTimeout, 0);
+      assert.strictEqual(opts.headersTimeout, 0);
     });
 
     it('maps maxResponseHeaderBytes to the undici Agent maxHeaderSize', 
function () {
@@ -99,12 +106,14 @@ describe('dispatcher', function () {
     it('omits bodyTimeout and maxHeaderSize when their options are unset', 
function () {
       const opts = buildAgentOptions();
       assert.strictEqual(opts.bodyTimeout, undefined);
+      assert.strictEqual(opts.headersTimeout, undefined);
       assert.strictEqual(opts.maxHeaderSize, undefined);
     });
 
     it('maps both readTimeoutMillis and maxResponseHeaderBytes together', 
function () {
       const opts = buildAgentOptions({ readTimeoutMillis: 2000, 
maxResponseHeaderBytes: 8192 });
       assert.strictEqual(opts.bodyTimeout, 2000);
+      assert.strictEqual(opts.headersTimeout, 2000);
       assert.strictEqual(opts.maxHeaderSize, 8192);
     });
 

Reply via email to