You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@juneau.apache.org by ja...@apache.org on 2026/10/06 12:14:31 UTC

(juneau) branch master updated: Serialize TOML non-scalar arrays (inline tables, runtime-decided [[x]]) and fix Prototext repeated fields/nested lists and compact separators; honor per-request timeouts in every HTTP transport (HC4.5, HC5, OkHttp, Jetty, Mock) and reject non-positive values; bind REST client test fixtures to 127.0.0.1 with class-level timeouts (fixes the rest-client-classic d07 hang); Parquet: drop unused ParquetType/LogicalType attributes and fail fast on unsupported compression codecs; BoundedServletInp [...]

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

jamesbognar pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/juneau.git


The following commit(s) were added to refs/heads/master by this push:
     new 321b6c2abe Serialize TOML non-scalar arrays (inline tables, runtime-decided [[x]]) and fix Prototext repeated fields/nested lists and compact separators; honor per-request timeouts in every HTTP transport (HC4.5, HC5, OkHttp, Jetty, Mock) and reject non-positive values; bind REST client test fixtures to 127.0.0.1 with class-level timeouts (fixes the rest-client-classic d07 hang); Parquet: drop unused ParquetType/LogicalType attributes and fail fast on unsupported compression code [...]
321b6c2abe is described below

commit 321b6c2abe2140670917462b76c72e2b52027255
Author: James Bognar <ja...@apache.org>
AuthorDate: Tue Oct 6 08:14:25 2026 -0400

    Serialize TOML non-scalar arrays (inline tables, runtime-decided [[x]]) and fix Prototext repeated fields/nested lists and compact separators; honor per-request timeouts in every HTTP transport (HC4.5, HC5, OkHttp, Jetty, Mock) and reject non-positive values; bind REST client test fixtures to 127.0.0.1 with class-level timeouts (fixes the rest-client-classic d07 hang); Parquet: drop unused ParquetType/LogicalType attributes and fail fast on unsupported compression codecs; BoundedServl [...]
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <no...@anthropic.com>
---
 .../juneau/commons/beanquery/SearchParser.java     |  30 ++-
 .../org/apache/juneau/commons/utils/IoUtils.java   |  24 +++
 .../apache/juneau/commons/utils/IoUtils_Test.java  |  25 +++
 .../juneau/marshall/parquet/CompressionCodec.java  |  24 +++
 .../apache/juneau/marshall/parquet/Parquet.java    |   9 +-
 .../juneau/marshall/parquet/ParquetAnnotation.java |   6 -
 .../juneau/marshall/parquet/ParquetConfig.java     |   5 +-
 .../marshall/parquet/ParquetConfigAnnotation.java  |   7 +-
 .../juneau/marshall/parquet/ParquetSerializer.java |   5 +-
 .../marshall/prototext/PrototextParserSession.java |   9 +
 .../marshall/prototext/PrototextSerializer.java    |   7 +
 .../prototext/PrototextSerializerSession.java      |  30 ++-
 .../juneau/marshall/prototext/PrototextWriter.java |   9 +-
 .../juneau/marshall/toml/TomlParserSession.java    |  34 +--
 .../marshall/toml/TomlSerializerSession.java       | 147 +++++++------
 .../marshall/parquet/ParquetAnnotation_Test.java   |  75 +++----
 .../parquet/ParquetSerializerBuilder_Test.java     |  22 ++
 .../prototext/PrototextRepeatedFields_Test.java    | 230 +++++++++++++++++++++
 .../juneau/marshall/toml/TomlArrays_Test.java      | 172 +++++++++++++++
 .../examples/mcp/ExampleMcpEndToEnd_Test.java      |  11 +-
 .../util/BoundedServletInputStream_Test.java       |  34 +++
 .../microservice/jetty/JettyServerComponent.java   |   4 +
 .../microservice/tomcat/TomcatServerComponent.java |   4 +
 .../apachehttpclient45/ApacheHc45Transport.java    |  19 +-
 ...acheHc45Transport_RedirectCredentials_Test.java |  16 +-
 ...cheHc45Transport_StaleConnectionRetry_Test.java |   7 +-
 .../rest/client/ApacheHc45Transport_Test.java      |   8 +-
 .../client/ApacheHc45Transport_Timeout_Test.java   | 125 +++++++++++
 .../apachehttpclient50/ApacheHc5Transport.java     |  17 +-
 .../rest/client/ApacheHc5Transport_Test.java       |   8 +-
 .../client/ApacheHc5Transport_Timeout_Test.java    | 125 +++++++++++
 .../client/classic/RemoteProxyParity_Test.java     |  12 +-
 .../classic/RemoteProxyThrowOnError_Test.java      |  12 +-
 .../classic/ResponseContent_As_Coverage_Test.java  |  16 +-
 .../classic/ResponseContent_Coverage_Test.java     |  14 +-
 .../classic/ResponseContent_Cursor_Test.java       |  12 +-
 .../rest/client/classic/ResponseHeader_Test.java   |  12 +-
 .../client/classic/ResponseStatusLine_Test.java    |  12 +-
 .../classic/RestClient_Builder_Coverage_Test.java  |  12 +-
 .../client/classic/RestClient_Coverage2_Test.java  |  12 +-
 .../classic/RestClient_FormPost_Coverage_Test.java |  12 +-
 .../RestClient_RedirectCredentials_Test.java       |  21 +-
 .../RestRequest_ArgExpansion_Coverage_Test.java    |  12 +-
 .../classic/RestRequest_Rethrow_Coverage_Test.java |  12 +-
 .../RestRequest_SessionOptions_Coverage_Test.java  |  12 +-
 ...RestRequest_StreamBodyEntity_Coverage_Test.java |  12 +-
 .../FluentResponseBodyAssertion_Test.java          |  14 +-
 .../rest/client/jetty/JettyHttpTransport.java      |   5 +-
 ...ettyHttpTransport_RedirectCredentials_Test.java |  16 +-
 ...ttyHttpTransport_StaleConnectionRetry_Test.java |   7 +-
 .../rest/client/JettyHttpTransport_Test.java       |   8 +-
 .../client/JettyHttpTransport_Timeout_Test.java    | 111 ++++++++++
 .../JettyHttpTransport_TransportFailure_Test.java  |  15 +-
 .../juneau/rest/client/okhttp/OkHttpTransport.java |   6 +-
 .../OkHttpTransport_RedirectCredentials_Test.java  |  16 +-
 .../juneau/rest/client/OkHttpTransport_Test.java   |   8 +-
 .../rest/client/OkHttpTransport_Timeout_Test.java  | 111 ++++++++++
 .../juneau/rest/client/JavaHttpTransport.java      |  10 +-
 .../org/apache/juneau/rest/client/RestRequest.java |  17 +-
 .../juneau/rest/client/TransportRequest.java       |   7 +-
 .../juneau/rest/client/remote/RemoteClient.java    |   5 +-
 ...avaHttpTransport_StaleConnectionRetry_Test.java |   7 +-
 .../juneau/rest/client/JavaHttpTransport_Test.java |  55 ++++-
 .../client/JavaHttpTransport_Timeout_Test.java     | 111 ++++++++++
 .../juneau/rest/client/RemoteClient_Test.java      |   8 +-
 .../rest/client/RestRequest_Timeout_Test.java      |  61 ++++++
 ...moteClient_InvocationHandlerInternals_Test.java |  14 ++
 .../remote/RemoteClient_ParseAndCursor_Test.java   |   8 +-
 .../RemoteClient_ReturnModesAndRetry_Test.java     |   8 +-
 .../apache/juneau/rest/mock/MockHttpTransport.java |  34 ++-
 .../rest/mock/MockHttpTransport_Timeout_Test.java  |  79 +++++++
 .../rest/server/mcp/AbstractMcpRestServlet.java    |  23 ++-
 .../server/mcp/BasicMcpSubscriptionBroker.java     |   7 +
 .../rest/server/mcp/McpSubscriptionBroker.java     |  13 ++
 .../mcp/BasicMcpSubscriptionBroker_Test.java       |  11 +
 .../juneau/rest/server/health/ReadinessState.java  |  37 ++++
 .../rest/server/health/ReadinessStateAware.java    |  60 ++++++
 .../rest/server/httppart/RequestContent.java       |  20 +-
 .../server/util/BoundedServletInputStream.java     |  32 ++-
 .../rest/server/health/ReadinessState_Test.java    |  55 +++++
 scripts/push.py                                    |  44 +++-
 scripts/test.py                                    |  85 ++++++++
 scripts/tests/README.md                            |   8 +-
 scripts/tests/test_js_tests_flags.py               | 152 ++++++++++++++
 84 files changed, 2423 insertions(+), 308 deletions(-)

diff --git a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/beanquery/SearchParser.java b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/beanquery/SearchParser.java
index 2cb6b5ca89..08d94afda6 100644
--- a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/beanquery/SearchParser.java
+++ b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/beanquery/SearchParser.java
@@ -80,10 +80,10 @@ final class SearchParser {
 			if (item != null)
 				items.add(item);
 			p.skipWs();
-			if (p.atEnd())
-				break;
-			p.expect(',');
-			p.skipWs();
+			if (! p.atEnd()) {
+				p.expect(',');
+				p.skipWs();
+			}
 		}
 		checkNoDuplicateTopLevelColumns(items);
 		return items;
@@ -147,7 +147,8 @@ final class SearchParser {
 			validateArity(kind, 0);
 			return null;
 		}
-		while (true) {
+		var closed = false;
+		while (! closed) {
 			rawCount++;
 			SearchItem child = parseItem();
 			if (child != null)
@@ -156,14 +157,12 @@ final class SearchParser {
 			if (atEnd())
 				throw new BeanQuerySyntaxException(BeanQuerySyntaxException.Code.UNBALANCED, "Unbalanced group in search.");
 			char c = peek();
-			if (c == ')') {
-				pos++;
-				break;
-			}
-			if (c != ',')
+			if (c != ')' && c != ',')
 				throw new BeanQuerySyntaxException(BeanQuerySyntaxException.Code.UNBALANCED, "Unbalanced group in search.");
 			pos++;
-			skipWs();
+			closed = c == ')';
+			if (! closed)
+				skipWs();
 		}
 		validateArity(kind, rawCount);
 		if (children.isEmpty())
@@ -197,14 +196,13 @@ final class SearchParser {
 			if (c == '\\' && pos + 1 < len) {
 				sb.append(s.charAt(pos + 1));
 				pos += 2;
-				continue;
-			}
-			if (c == '=') {
+			} else if (c == '=') {
 				pos++;
 				return sb.toString();
+			} else {
+				sb.append(c);
+				pos++;
 			}
-			sb.append(c);
-			pos++;
 		}
 		throw new BeanQuerySyntaxException(BeanQuerySyntaxException.Code.UNBALANCED, "Missing '=' after column '%s' in search.", sb);
 	}
diff --git a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/utils/IoUtils.java b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/utils/IoUtils.java
index bb9d922eaf..472450e432 100644
--- a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/utils/IoUtils.java
+++ b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/utils/IoUtils.java
@@ -129,6 +129,30 @@ public class IoUtils {
 			throw ex;
 	}
 
+	/**
+	 * Reads and discards the remainder of a reader or input stream, ignoring any exceptions.
+	 *
+	 * <p>
+	 * Reads until end-of-stream using a 256-unit buffer.  Any {@link IOException} stops the drain silently.
+	 * No-op if the object is <jk>null</jk> or is neither a {@link Reader} nor an {@link InputStream}.
+	 * The stream is not closed.
+	 *
+	 * @param in The reader or input stream to drain.
+	 */
+	public static void drainQuietly(Closeable in) {
+		try {
+			if (in instanceof Reader r) {
+				var buf = new char[256];
+				while (r.read(buf) != -1) { /* discard */ }
+			} else if (in instanceof InputStream is) {
+				var buf = new byte[256];
+				while (is.read(buf) != -1) { /* discard */ }
+			}
+		} catch (IOException ignored) {
+			// best-effort
+		}
+	}
+
 	/**
 	 * Close input stream and ignore any exceptions.
 	 *
diff --git a/juneau-core/juneau-commons/src/test/java/org/apache/juneau/commons/utils/IoUtils_Test.java b/juneau-core/juneau-commons/src/test/java/org/apache/juneau/commons/utils/IoUtils_Test.java
index c3043bf8f6..539d2c8b70 100644
--- a/juneau-core/juneau-commons/src/test/java/org/apache/juneau/commons/utils/IoUtils_Test.java
+++ b/juneau-core/juneau-commons/src/test/java/org/apache/juneau/commons/utils/IoUtils_Test.java
@@ -81,6 +81,31 @@ class IoUtils_Test extends TestBase {
 		assertThrows(IOException.class, () -> close(throwingIs, throwingOs, throwingR, throwingW));
 	}
 
+	//====================================================================================================
+	// drainQuietly(Closeable)
+	//====================================================================================================
+	@Test
+	void a001b_drainQuietly() throws IOException {
+		var is = new ByteArrayInputStream(new byte[1000]);
+		drainQuietly(is);
+		assertEquals(-1, is.read());
+
+		var r = new StringReader("x".repeat(1000));
+		drainQuietly(r);
+		assertEquals(-1, r.read());
+
+		// Throwing stream: exception swallowed.
+		assertDoesNotThrow(() -> drainQuietly(new InputStream() {
+			@Override public int read() throws IOException { throw new IOException("Test exception"); }
+		}));
+
+		// Non-stream Closeable and null: no-op, not closed.
+		var closed = new AtomicBoolean();
+		drainQuietly((Closeable)() -> closed.set(true));
+		drainQuietly(null);
+		assertFalse(closed.get());
+	}
+
 	//====================================================================================================
 	// closeQuietly(InputStream)
 	//====================================================================================================
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/parquet/CompressionCodec.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/parquet/CompressionCodec.java
index f2cdabe4b7..9c413bc0e7 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/parquet/CompressionCodec.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/parquet/CompressionCodec.java
@@ -106,6 +106,30 @@ public enum CompressionCodec {
 		this.thriftValue = thriftValue;
 	}
 
+	/**
+	 * Resolves a codec name for <b>writing</b>.
+	 *
+	 * @param name The codec name (case-insensitive).  Only <c>UNCOMPRESSED</c> and <c>GZIP</c> are writable.
+	 * @return The matching codec.
+	 * @throws IllegalArgumentException If the name is unknown or names a decode-only codec such as <c>SNAPPY</c>.
+	 */
+	static CompressionCodec forWrite(String name) {
+		for (var c : values())
+			if (c.writable() && c.name().equalsIgnoreCase(name))
+				return c;
+		throw forWriteError(name);
+	}
+
+	static IllegalArgumentException forWriteError(String name) {
+		return new IllegalArgumentException("Unsupported Parquet compression codec for writing: '" + name
+			+ "'. Valid write codecs: UNCOMPRESSED, GZIP."
+			+ (SNAPPY.name().equalsIgnoreCase(name) ? " SNAPPY is decode-only and cannot be written." : ""));
+	}
+
+	boolean writable() {
+		return this != SNAPPY;
+	}
+
 	abstract byte[] compress(byte[] data) throws IOException;
 
 	abstract byte[] decompress(byte[] data, int uncompressedSize) throws IOException;
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/parquet/Parquet.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/parquet/Parquet.java
index 70179cba09..d3bda5e031 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/parquet/Parquet.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/parquet/Parquet.java
@@ -31,11 +31,4 @@ import java.lang.annotation.*;
 @Target({ TYPE, METHOD, FIELD })
 @Retention(RUNTIME)
 @Inherited
-public @interface Parquet {
-
-	/** Override Parquet physical type.  <b>Reserved:</b> currently read by neither the serializer nor the parser, so it has no effect. */
-	String parquetType() default "";
-
-	/** Override logical type.  <b>Reserved:</b> currently read by neither the serializer nor the parser, so it has no effect. */
-	String logicalType() default "";
-}
+public @interface Parquet {}
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/parquet/ParquetAnnotation.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/parquet/ParquetAnnotation.java
index c8fbf85c4f..8d56ae02d2 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/parquet/ParquetAnnotation.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/parquet/ParquetAnnotation.java
@@ -31,12 +31,6 @@ public class ParquetAnnotation {
 		Object() {
 			super(new AnnotationObject.Builder(Parquet.class));
 		}
-
-		@Override
-		public String parquetType() { return ""; }
-
-		@Override
-		public String logicalType() { return ""; }
 	}
 
 	/** Default value */
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/parquet/ParquetConfig.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/parquet/ParquetConfig.java
index 20e5e5c7f0..81e91c0452 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/parquet/ParquetConfig.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/parquet/ParquetConfig.java
@@ -41,8 +41,9 @@ public @interface ParquetConfig {
 	 * Compression codec used when <b>writing</b> (<js>"UNCOMPRESSED"</js> or <js>"GZIP"</js>, case-insensitive).
 	 *
 	 * <p>
-	 * Any other value (including <js>"SNAPPY"</js>, which the parser can decode but the serializer cannot write)
-	 * falls back to <js>"UNCOMPRESSED"</js>.  The parser detects the codec from the file and ignores this setting.
+	 * Any other value fails fast with an {@link IllegalArgumentException} naming the bad value and the valid write
+	 * codecs.  This includes unknown names (typos) and <js>"SNAPPY"</js>, which the parser can decode but the
+	 * serializer cannot write.  The parser detects the codec from the file and ignores this setting.
 	 */
 	String compressionCodec() default "";
 
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/parquet/ParquetConfigAnnotation.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/parquet/ParquetConfigAnnotation.java
index 609c813dc6..5ad7a67489 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/parquet/ParquetConfigAnnotation.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/parquet/ParquetConfigAnnotation.java
@@ -51,11 +51,8 @@ public class ParquetConfigAnnotation {
 		@Override
 		public void apply(AnnotationInfo<ParquetConfig> ai, ParquetSerializer.Builder b) {
 			var a = ai.inner();
-			if (!a.compressionCodec().isEmpty()) {
-				var cc = eqic("GZIP", a.compressionCodec())
-					? CompressionCodec.GZIP : CompressionCodec.UNCOMPRESSED;
-				b.compressionCodec(cc);
-			}
+			if (!a.compressionCodec().isEmpty())
+				b.compressionCodec(CompressionCodec.forWrite(a.compressionCodec()));
 			if (!a.rowGroupSize().isEmpty())
 				b.rowGroupSize(Integer.parseInt(a.rowGroupSize()));
 			if (!a.pageSize().isEmpty())
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/parquet/ParquetSerializer.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/parquet/ParquetSerializer.java
index d8d0b79060..be6e1709e5 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/parquet/ParquetSerializer.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/parquet/ParquetSerializer.java
@@ -123,10 +123,13 @@ public class ParquetSerializer extends OutputStreamSerializer implements Parquet
 		/**
 		 * Sets the compression codec.
 		 *
-		 * @param value UNCOMPRESSED or GZIP.
+		 * @param value UNCOMPRESSED or GZIP.  <jk>null</jk> resets to UNCOMPRESSED.
 		 * @return This object.
+		 * @throws IllegalArgumentException If the codec cannot be written (e.g. SNAPPY, which is decode-only).
 		 */
 		public Builder compressionCodec(CompressionCodec value) {
+			if (value != null && ! value.writable())
+				throw CompressionCodec.forWriteError(value.name());
 			compressionCodec = or(value, CompressionCodec.UNCOMPRESSED);
 			return this;
 		}
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/prototext/PrototextParserSession.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/prototext/PrototextParserSession.java
index ac7fc26bfe..5c82b96a7d 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/prototext/PrototextParserSession.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/prototext/PrototextParserSession.java
@@ -324,6 +324,15 @@ public class PrototextParserSession extends ReaderParserSession implements Recor
 	private Object convertValue(Object val, ClassMeta<?> targetType) throws ParseException, ExecutableException {
 		if (val == null)
 			return null;
+		// A repeated field with a single occurrence parses as a bare value rather than a one-element list,
+		// and a nested list is wrapped in a "_value" message (see PrototextSerializerSession); normalize both
+		// before dispatching on the declared collection/array type.
+		if (targetType.isCollectionOrArray() && targetType.inner() != byte[].class) {
+			if (val instanceof Map val2 && val2.size() == 1 && val2.containsKey("_value"))
+				val = val2.get("_value");
+			if (!(val instanceof List))
+				val = l(val);
+		}
 		if (val instanceof Map val2) {
 			if (JsonMap.class.isAssignableFrom(targetType.inner()))
 				return toJsonMap(val2);
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/prototext/PrototextSerializer.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/prototext/PrototextSerializer.java
index 215ec71c2e..b08b3ddfd7 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/prototext/PrototextSerializer.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/prototext/PrototextSerializer.java
@@ -54,6 +54,13 @@ import org.apache.juneau.marshall.stream.*;
  * 	<li>
  * 		Collections of beans are converted to repeated field names.
  * 	<li>
+ * 		Nested collections/arrays (e.g. <c>List&lt;List&lt;Integer&gt;&gt;</c>) have no native protobuf text syntax, so each inner
+ * 		list is written as a repeated message wrapping a single <c>_value</c> field (e.g. <c>grid {_value: [2, 3]}</c>).
+ * 		{@link PrototextParser} unwraps these when the target type is a list or array; parsing into an untyped
+ * 		map leaves the <c>_value</c> wrapper in place.
+ * 	<li>
+ * 		Every message ends with a newline, with or without whitespace enabled, so fields are always separated.
+ * 	<li>
  * 		{@link String Strings} are converted to quoted strings with C-style escaping.
  * 	<li>
  * 		{@link Number Numbers} (e.g. {@link Integer}, {@link Long}, {@link Double}) are converted to numeric literals.
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/prototext/PrototextSerializerSession.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/prototext/PrototextSerializerSession.java
index 08990531f0..e8650a0162 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/prototext/PrototextSerializerSession.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/prototext/PrototextSerializerSession.java
@@ -285,7 +285,12 @@ public class PrototextSerializerSession extends WriterSerializerSession implemen
 				// List<ChildBean>/List<Map> bean property still resolves to nested messages instead of
 				// degrading to scalar toString().
 				var elType = cMeta.isCollectionOrArray() ? cMeta.getElementType() : aType.getElementType();
-				if (elType.isBean() || elType.isMap()) {
+				var nested = isNestedCollection(elType);
+				if (!nested && elType.isObject() && !c.isEmpty())
+					nested = isNestedCollection(getClassMetaForObject(c.iterator().next()));
+				if (nested) {
+					writeCollection(out, c, cMeta.isCollectionOrArray() ? cMeta : aType, key);
+				} else if (elType.isBean() || elType.isMap()) {
 					if (ctx.useListSyntaxForBeans) {
 						out.scalarField(key);
 						out.listStart();
@@ -365,10 +370,24 @@ public class PrototextSerializerSession extends WriterSerializerSession implemen
 			writeBeanMap(out, toBeanMap(item), null);
 		else if (aType.isMap())
 			writeMap(out, (Map) item, aType);
-		else
+		else if (isNestedCollection(aType)) {
+			out.cr(indent);
+			writeCollection(out, aType.isArray() ? toList(aType.inner(), item) : (Collection<?>) item, aType, "_value");
+		} else
 			writeScalarValue(out, item, aType);
 	}
 
+	/**
+	 * Returns <jk>true</jk> if the type is a collection or array that must be written as a nested message.
+	 *
+	 * <p>
+	 * Protobuf text format has no list-of-lists syntax, so each inner list is written as a message containing
+	 * a single <c>_value</c> field (e.g. <c>grid { _value: [1, 2] }</c>).  <c>byte[]</c> is a scalar.
+	 */
+	private static boolean isNestedCollection(ClassMeta<?> type) {
+		return type.isCollection() || (type.isArray() && type.inner() != byte[].class);
+	}
+
 	private void writeBeanOrMapItem(PrototextWriter out, Object item) throws SerializeException {
 		var aType = getClassMetaForObject(item);
 		if (aType.isMap())
@@ -380,10 +399,11 @@ public class PrototextSerializerSession extends WriterSerializerSession implemen
 	private void writeCollection(PrototextWriter out, Collection<?> c, ClassMeta<?> type, String fieldName) throws SerializeException {
 		var elType = type.getElementType();
 		// When element type is Object, resolve actual type from first element for beans/maps
-		var elementIsBeanOrMap = elType.isBean() || elType.isMap();
+		// Nested collections/arrays are written as one message per element wrapping a "_value" field.
+		var elementIsBeanOrMap = elType.isBean() || elType.isMap() || isNestedCollection(elType);
 		if (!elementIsBeanOrMap && elType.isObject() && !c.isEmpty()) {
-			var first = c.iterator().next();
-			elementIsBeanOrMap = getClassMetaForObject(first).isBean() || getClassMetaForObject(first).isMap();
+			var firstType = getClassMetaForObject(c.iterator().next());
+			elementIsBeanOrMap = firstType.isBean() || firstType.isMap() || isNestedCollection(firstType);
 		}
 		if (elementIsBeanOrMap) {
 			if (ctx.useListSyntaxForBeans && nn(fieldName)) {
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/prototext/PrototextWriter.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/prototext/PrototextWriter.java
index 099e5de523..f753dddfae 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/prototext/PrototextWriter.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/prototext/PrototextWriter.java
@@ -106,14 +106,17 @@ public class PrototextWriter extends SerializerWriter<PrototextWriter> {
 	}
 
 	/**
-	 * Writes the end of a message: <c>}</c> with optional newline.
+	 * Writes the end of a message: <c>}</c> followed by a newline.
+	 *
+	 * <p>
+	 * The newline is always written (even without whitespace) so the closing brace is never directly
+	 * adjacent to the next field name; this matches scalar fields, which always end with a newline.
 	 *
 	 * @param depth The current indentation depth for the closing brace.
 	 * @return This object.
 	 */
 	public PrototextWriter messageEnd(int depth) {
-		i(depth).w('}');
-		nl(depth);
+		i(depth).w('}').w('\n');
 		return this;
 	}
 
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/toml/TomlParserSession.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/toml/TomlParserSession.java
index a784f70bf4..254cd7b3e0 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/toml/TomlParserSession.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/toml/TomlParserSession.java
@@ -374,31 +374,41 @@ public class TomlParserSession extends ReaderParserSession implements RecordRead
 		}
 	}
 
+	// Steps into a child table.  Per TOML, a header path that traverses an array of tables refers to its most recent element.
+	private static Object stepInto(Object current, String key) {
+		if (current instanceof List l && !l.isEmpty())
+			current = l.get(l.size() - 1);
+		return ((Map)current).computeIfAbsent(key, k -> new LinkedHashMap<String,Object>());
+	}
+
+	private static Map<String,Object> lastTable(Object current) {
+		if (current instanceof List l && !l.isEmpty())
+			current = l.get(l.size() - 1);
+		return (Map<String,Object>)current;
+	}
+
 	private static Object getOrCreateAt(Map<String,Object> root, String path) {
 		String[] parts = path.split("\\.");
 		Object current = root;
-		for (int i = 0; i < parts.length - 1; i++) {
-			current = ((Map)current).computeIfAbsent(parts[i], k -> new LinkedHashMap<String,Object>());
-		}
-		return ((Map)current).get(parts[parts.length - 1]);
+		for (int i = 0; i < parts.length - 1; i++)
+			current = stepInto(current, parts[i]);
+		return lastTable(current).get(parts[parts.length - 1]);
 	}
 
 	private static Map<String,Object> getOrCreateTableAt(Map<String,Object> root, String path) {
 		String[] parts = path.split("\\.");
 		Object current = root;
-		for (String part : parts) {
-			current = ((Map)current).computeIfAbsent(part, k -> new LinkedHashMap<String,Object>());
-		}
-		return (Map<String,Object>) current;
+		for (String part : parts)
+			current = stepInto(current, part);
+		return lastTable(current);
 	}
 
 	private static void setAt(Map<String,Object> root, String path, Object value) {
 		String[] parts = path.split("\\.");
 		Object current = root;
-		for (int i = 0; i < parts.length - 1; i++) {
-			current = ((Map)current).computeIfAbsent(parts[i], k -> new LinkedHashMap<String,Object>());
-		}
-		((Map)current).put(parts[parts.length - 1], value);
+		for (int i = 0; i < parts.length - 1; i++)
+			current = stepInto(current, parts[i]);
+		lastTable(current).put(parts[parts.length - 1], value);
 	}
 
 	private static void setValueAt(Map<String,Object> root, List<String> keyPath, Object value) {
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/toml/TomlSerializerSession.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/toml/TomlSerializerSession.java
index 7fd3016982..548f6a6d0b 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/toml/TomlSerializerSession.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/toml/TomlSerializerSession.java
@@ -203,20 +203,15 @@ public class TomlSerializerSession extends WriterSerializerSession implements Re
 				} else if (aType.isMap()) {
 					w.tableHeader(newPath);
 					writeMapAsTable(w, newPath, (Map)value, cMeta);
-				} else if (aType.isCollection() || aType.isArray()) {
-					Collection<?> col = aType.isArray() ? toList(aType.inner(), value) : (Collection<?>)value;
-					ClassMeta<?> elType = aType.getElementType();
-					if (elType.isBean() || elType.isMap()) {
-						for (Object item : col) {
-							w.blankLine();
-							w.arrayOfTablesHeader(newPath);
-							if (elType.isBean())
-								writeBean(w, toBeanMap(item), newPath);
-							else
-								writeMapAsTable(w, newPath, (Map)item, elType);
-						}
-					} else {
-						writeKeyValue(w, key, value, pMeta);
+				} else {
+					// Only table-worthy collections/arrays (non-empty, all bean/map elements) reach this bucket.
+					for (Object item : tableElements(value)) {
+						w.blankLine();
+						w.arrayOfTablesHeader(newPath);
+						if (item instanceof Map<?,?> im)
+							writeMapAsTable(w, newPath, im, getClassMetaForObject(item));
+						else
+							writeBean(w, toBeanMap(item), newPath);
 					}
 				}
 			} else if (isKeepNullProperties()) {
@@ -286,29 +281,19 @@ public class TomlSerializerSession extends WriterSerializerSession implements Re
 			rType = swap.getSwapClassMeta(this);
 			if (rType.isObject())
 				rType = getClassMetaForObject(value);
-			aType = rType;
 		}
+		// Always dispatch on the runtime type: the declared/hinted type is often erased (e.g. Object for
+		// the elements of a List<Integer> whose runtime class is a raw List).
+		aType = rType;
 		if (value == null) {
 			w.stringValue(ctx.getNullValue());
 			return;
 		}
+		// A bean or map in value position (after '=' or inside an array) can only be an inline table.
 		if (aType.isBean()) {
-			BeanMap<?> bm = toBeanMap(value);
-			if (shouldUseInlineTable(bm)) {
-				w.inlineTableStart();
-				boolean[] first = {true};
-				Predicate<Object> checkNull = x -> isKeepNullProperties() || nn(x);
-				bm.forEachValue(checkNull, (pm, k, v, th) -> {
-					if (!first[0]) w.w(", ");
-					first[0] = false;
-					writeKey(w, k);
-					w.w(" = ");
-				writeValue(w, v, (ClassMeta<?>) pm.getBeanInfo(), pm);
-				});
-				w.inlineTableEnd();
-			} else {
-				writeBean(w, bm, "");
-			}
+			writeInlineTable(w, toBeanMap(value));
+		} else if (aType.isMap()) {
+			writeInlineTable(w, (Map<?,?>)value);
 		} else if (aType.isNumber()) {
 			if (value instanceof Float || value instanceof Double)
 				w.floatValue(((Number)value).doubleValue());
@@ -363,22 +348,18 @@ public class TomlSerializerSession extends WriterSerializerSession implements Re
 		} else if (aType.isCollection() || aType.isArray()) {
 			Collection<?> c = aType.isArray() ? toList(aType.inner(), value) : (Collection<?>)value;
 			ClassMeta<?> elType = aType.getElementType();
-			if (elType.isBean() || elType.isMap()) {
-				// Should have been handled in complex pass
-				w.stringValue(value.toString());
-			} else {
-				w.arrayStart();
-				boolean first = true;
-				for (Object el : c) {
-					if (!first) w.w(", ");
-					first = false;
-					if (el == null)
-						w.stringValue(ctx.getNullValue());
-					else
-						writeValue(w, el, elType, null);
-				}
-				w.arrayEnd();
+			// Beans/maps become inline tables inside the array; nested collections become nested arrays.
+			w.arrayStart();
+			boolean first = true;
+			for (Object el : c) {
+				if (!first) w.w(", ");
+				first = false;
+				if (el == null)
+					w.stringValue(ctx.getNullValue());
+				else
+					writeValue(w, el, elType, null);
 			}
+			w.arrayEnd();
 		} else {
 			w.stringValue(toString(value));
 		}
@@ -388,6 +369,60 @@ public class TomlSerializerSession extends WriterSerializerSession implements Re
 		writeValue(w, value, aType, null);
 	}
 
+	private void writeInlineTable(TomlWriter w, BeanMap<?> bm) throws SerializeException {
+		w.inlineTableStart();
+		boolean[] first = {true};
+		Predicate<Object> checkNull = x -> isKeepNullProperties() || nn(x);
+		bm.forEachValue(checkNull, (pm, k, v, th) -> {
+			if (!first[0]) w.w(", ");
+			first[0] = false;
+			writeKey(w, k);
+			w.w(" = ");
+			writeValue(w, v, (ClassMeta<?>) pm.getBeanInfo(), pm);
+		});
+		w.inlineTableEnd();
+	}
+
+	private void writeInlineTable(TomlWriter w, Map<?,?> map) throws SerializeException {
+		w.inlineTableStart();
+		boolean[] first = {true};
+		Predicate<Object> checkNull = x -> isKeepNullProperties() || nn(x);
+		forEachEntry(map, e -> {
+			Object v = e.getValue();
+			if (!checkNull.test(v))
+				return;
+			if (!first[0]) w.w(", ");
+			first[0] = false;
+			writeKey(w, e.getKey() == null ? "null" : toString(e.getKey()));
+			w.w(" = ");
+			writeValue(w, v, object(), null);
+		});
+		w.inlineTableEnd();
+	}
+
+	/**
+	 * Returns the elements of a collection/array value if it is "table-worthy" (non-empty with every element a
+	 * non-null bean or map, so it can be written as a TOML array of tables), otherwise <jk>null</jk>.
+	 */
+	private List<Object> tableElements(Object value) throws SerializeException {
+		List<Object> l;
+		if (value instanceof Collection<?> c)
+			l = new ArrayList<>(c);
+		else if (value != null && value.getClass().isArray() && !value.getClass().getComponentType().isPrimitive())
+			l = new ArrayList<>(Arrays.asList((Object[])value));
+		else
+			return null;
+		if (l.isEmpty())
+			return null;
+		for (Object el : l) {
+			if (el instanceof Map)
+				continue;
+			if (el == null || !getClassMetaForObject(el).isBean())
+				return null;
+		}
+		return l;
+	}
+
 	private void writeMapAsTable(TomlWriter w, String path, Map<?,?> map, ClassMeta<?> type) throws SerializeException {
 		Predicate<Object> checkNull = x -> isKeepNullProperties() || nn(x);
 		forEachEntry(map, e -> {
@@ -412,8 +447,8 @@ public class TomlSerializerSession extends WriterSerializerSession implements Re
 			w.blankLine();
 			w.tableHeader(tablePath);
 			writeMapAsTable(w, tablePath, v2, getClassMetaForObject(value, hint));
-		} else if (value instanceof Collection<?> c && isTableWorthyCollection(c)) {
-			for (Object item : c) {
+		} else if (tableElements(value) != null) {
+			for (Object item : tableElements(value)) {
 				w.blankLine();
 				w.arrayOfTablesHeader(tablePath);
 				if (item instanceof Map<?,?> im)
@@ -426,18 +461,6 @@ public class TomlSerializerSession extends WriterSerializerSession implements Re
 		}
 	}
 
-	private boolean isTableWorthyCollection(Collection<?> c) throws SerializeException {
-		if (c.isEmpty())
-			return false;
-		for (Object el : c) {
-			if (el instanceof Map)
-				continue;
-			if (el == null || !getClassMetaForObject(el).isBean())
-				return false;
-		}
-		return true;
-	}
-
 	private void writeArray(TomlWriter w, Collection<?> c, ClassMeta<?> type) throws SerializeException {
 		ClassMeta<?> elType = type.getElementType();
 		w.arrayStart();
@@ -455,7 +478,9 @@ public class TomlSerializerSession extends WriterSerializerSession implements Re
 			BeanMap<?> bm = toBeanMap(value);
 			return shouldUseInlineTable(bm);
 		}
-		return !aType.isMap() && !(aType.isCollectionOrArray() && aType.getElementType().isBean());
+		if (aType.isCollectionOrArray())
+			return tableElements(value) == null;
+		return !aType.isMap();
 	}
 
 	private boolean shouldUseInlineTable(BeanMap<?> bm) {
diff --git a/juneau-core/juneau-marshall/src/test/java/org/apache/juneau/marshall/parquet/ParquetAnnotation_Test.java b/juneau-core/juneau-marshall/src/test/java/org/apache/juneau/marshall/parquet/ParquetAnnotation_Test.java
index 77b673c13c..a6e462d33b 100644
--- a/juneau-core/juneau-marshall/src/test/java/org/apache/juneau/marshall/parquet/ParquetAnnotation_Test.java
+++ b/juneau-core/juneau-marshall/src/test/java/org/apache/juneau/marshall/parquet/ParquetAnnotation_Test.java
@@ -30,8 +30,6 @@ class ParquetAnnotation_Test {
 	@Test void a01_defaultValue() {
 		var a = ParquetAnnotation.DEFAULT;
 		assertNotNull(a);
-		assertEquals("", a.parquetType());
-		assertEquals("", a.logicalType());
 	}
 
 	@Test void a02_defaultEquality() {
@@ -54,8 +52,6 @@ class ParquetAnnotation_Test {
 	@Test void d01_defaultDeclarativeAnnotations() {
 		var d1 = D1.class.getAnnotationsByType(Parquet.class)[0];
 		var d2 = D2.class.getAnnotationsByType(Parquet.class)[0];
-		assertEquals("", d1.parquetType());
-		assertEquals("", d1.logicalType());
 		assertEquals(d1, d2);
 		assertEquals(d1.hashCode(), d2.hashCode());
 	}
@@ -65,34 +61,6 @@ class ParquetAnnotation_Test {
 		assertEquals(ParquetAnnotation.DEFAULT, d1);
 	}
 
-	//------------------------------------------------------------------------------------------------------------------
-	// Comparison with declared annotations — explicit values.
-	//------------------------------------------------------------------------------------------------------------------
-
-	@Parquet(parquetType="BYTE_ARRAY", logicalType="STRING")
-	public static class D3 {}
-
-	@Parquet(parquetType="BYTE_ARRAY", logicalType="STRING")
-	public static class D4 {}
-
-	@Test void d03_explicitValues() {
-		var d3 = D3.class.getAnnotationsByType(Parquet.class)[0];
-		assertEquals("BYTE_ARRAY", d3.parquetType());
-		assertEquals("STRING", d3.logicalType());
-	}
-
-	@Test void d04_explicitEquality() {
-		var d3 = D3.class.getAnnotationsByType(Parquet.class)[0];
-		var d4 = D4.class.getAnnotationsByType(Parquet.class)[0];
-		assertEquals(d3, d4);
-		assertEquals(d3.hashCode(), d4.hashCode());
-	}
-
-	@Test void d05_explicitNotEqualDefault() {
-		var d3 = D3.class.getAnnotationsByType(Parquet.class)[0];
-		assertNotEquals(ParquetAnnotation.DEFAULT, d3);
-	}
-
 	//------------------------------------------------------------------------------------------------------------------
 	// ParquetApplyAnnotation tests.
 	//------------------------------------------------------------------------------------------------------------------
@@ -134,10 +102,45 @@ class ParquetAnnotation_Test {
 	@ParquetConfig(compressionCodec = "SNAPPY")
 	public static class G01b_Class {}
 
-	@Test void g01b_parquetConfigNonGzipCodecFallsToUncompressed() {
-		// "SNAPPY" is not "GZIP" → ternary false branch → CompressionCodec.UNCOMPRESSED
-		var s = ParquetSerializer.create().applyAnnotations(G01b_Class.class).build();
-		assertNotNull(s);
+	@Test void g01b_parquetConfigSnappyWriteFailsFast() {
+		var ex = assertThrows(RuntimeException.class, () -> ParquetSerializer.create().applyAnnotations(G01b_Class.class).build());
+		assertTrue(messages(ex).contains("SNAPPY"), () -> messages(ex));
+		assertTrue(messages(ex).contains("UNCOMPRESSED, GZIP"), () -> messages(ex));
+	}
+
+	@ParquetConfig(compressionCodec = "GZPI")
+	public static class G01c_Class {}
+
+	@Test void g01c_parquetConfigTypoFailsFast() {
+		var ex = assertThrows(RuntimeException.class, () -> ParquetSerializer.create().applyAnnotations(G01c_Class.class).build());
+		assertTrue(messages(ex).contains("GZPI"), () -> messages(ex));
+		assertTrue(messages(ex).contains("UNCOMPRESSED, GZIP"), () -> messages(ex));
+	}
+
+	@ParquetConfig(compressionCodec = "gzip")
+	public static class G01d_Class {}
+
+	@ParquetConfig(compressionCodec = "Uncompressed")
+	public static class G01e_Class {}
+
+	@Test void g01d_parquetConfigValidCodecsCaseInsensitive() throws Exception {
+		for (var c : new Class<?>[] { G01d_Class.class, G01e_Class.class }) {
+			var s = ParquetSerializer.create().applyAnnotations(c).build();
+			var out = (java.util.List<ParquetSerializerBuilder_Test.KBean>) ParquetParser.DEFAULT.read(
+				s.write(java.util.List.of(new ParquetSerializerBuilder_Test.KBean("v"))), java.util.List.class, ParquetSerializerBuilder_Test.KBean.class);
+			assertEquals("v", out.get(0).k);
+		}
+	}
+
+	@Test void g01f_parquetConfigParserIgnoresCodec() {
+		assertNotNull(ParquetParser.create().applyAnnotations(G01b_Class.class).build());
+	}
+
+	private static String messages(Throwable t) {
+		var sb = new StringBuilder();
+		for (; t != null; t = t.getCause())
+			sb.append(t.getMessage()).append(" | ");
+		return sb.toString();
 	}
 
 	@ParquetConfig(addBeanTypes = "true")
diff --git a/juneau-core/juneau-marshall/src/test/java/org/apache/juneau/marshall/parquet/ParquetSerializerBuilder_Test.java b/juneau-core/juneau-marshall/src/test/java/org/apache/juneau/marshall/parquet/ParquetSerializerBuilder_Test.java
index acf47331b1..55048466f1 100644
--- a/juneau-core/juneau-marshall/src/test/java/org/apache/juneau/marshall/parquet/ParquetSerializerBuilder_Test.java
+++ b/juneau-core/juneau-marshall/src/test/java/org/apache/juneau/marshall/parquet/ParquetSerializerBuilder_Test.java
@@ -138,4 +138,26 @@ class ParquetSerializerBuilder_Test extends TestBase {
 		}
 		assertNotNull(sb);
 	}
+
+	@Test void z01_builderSnappyFailsFast() {
+		var b = ParquetSerializer.create();
+		var ex = assertThrows(IllegalArgumentException.class, () -> b.compressionCodec(CompressionCodec.SNAPPY));
+		assertTrue(ex.getMessage().contains("SNAPPY"));
+		assertTrue(ex.getMessage().contains("UNCOMPRESSED, GZIP"));
+	}
+
+	@Test void z02_validWriteCodecsRoundTrip() throws Exception {
+		for (var c : new CompressionCodec[] { CompressionCodec.UNCOMPRESSED, CompressionCodec.GZIP }) {
+			var s = ParquetSerializer.create().compressionCodec(c).build();
+			var out = (List<KBean>) ParquetParser.DEFAULT.read(s.write(list(new KBean("v"))), List.class, KBean.class);
+			assertEquals("v", out.get(0).k);
+		}
+	}
+
+	@Test void z03_forWriteResolvesCaseInsensitive() {
+		assertSame(CompressionCodec.GZIP, CompressionCodec.forWrite("gZiP"));
+		assertSame(CompressionCodec.UNCOMPRESSED, CompressionCodec.forWrite("uncompressed"));
+		var ex = assertThrows(IllegalArgumentException.class, () -> CompressionCodec.forWrite("bogus"));
+		assertTrue(ex.getMessage().contains("'bogus'"));
+	}
 }
diff --git a/juneau-core/juneau-marshall/src/test/java/org/apache/juneau/marshall/prototext/PrototextRepeatedFields_Test.java b/juneau-core/juneau-marshall/src/test/java/org/apache/juneau/marshall/prototext/PrototextRepeatedFields_Test.java
new file mode 100644
index 0000000000..1a9e0ed7fe
--- /dev/null
+++ b/juneau-core/juneau-marshall/src/test/java/org/apache/juneau/marshall/prototext/PrototextRepeatedFields_Test.java
@@ -0,0 +1,230 @@
+// ***************************************************************************************************************************
+// * 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.juneau.marshall.prototext;
+
+import static org.apache.juneau.test.bct.BctAssertions.*;
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.*;
+
+import org.apache.juneau.marshall.collections.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Round-trip tests for repeated fields (lists, arrays, nested messages) in compact and whitespace modes (WORK-J0595).
+ */
+class PrototextRepeatedFields_Test {
+
+	private static final PrototextSerializer COMPACT = PrototextSerializer.DEFAULT;
+	private static final PrototextSerializer WS = PrototextSerializer.create().ws().build();
+	private static final PrototextParser P = PrototextParser.DEFAULT;
+
+	public static class Inner {
+		public String name;
+		public int n;
+		public Inner() {}
+		public Inner(String name, int n) { this.name = name; this.n = n; }
+	}
+
+	public static class Scalars { public List<Integer> nums; }
+	public static class Beans { public List<Inner> ins; }
+	public static class BeanArray { public Inner[] ins; }
+	public static class Maps { public Map<String,Integer> m; }
+	public static class MapList { public List<Map<String,Object>> ms; }
+	public static class Nested { public List<List<Integer>> grid; }
+	public static class Empty { public List<Integer> a; public List<Inner> b; public String s; }
+	public static class Mid { public String title; public List<Beans> parts; }
+	public static class Untyped { public Object items; public Map<String,List<Inner>> byKey; public Inner[][] grid; }
+	public static class Mixed { public Beans first; public List<Integer> nums; public List<Inner> ins; public String last; }
+
+	private static void forEachMode(java.util.function.Consumer<PrototextSerializer> c) {
+		c.accept(COMPACT);
+		c.accept(WS);
+	}
+
+	@Test void a01_scalarList() {
+		forEachMode(s -> {
+			try {
+				var b = new Scalars();
+				b.nums = List.of(1, 2, 3);
+				var r = P.read(s.write(b), Scalars.class);
+				assertBean(r, "nums", "[1,2,3]");
+			} catch (Exception e) { throw new AssertionError(e); }
+		});
+	}
+
+	@Test void a02_beanList() {
+		forEachMode(s -> {
+			try {
+				var b = new Beans();
+				b.ins = List.of(new Inner("a", 1), new Inner("b", 2));
+				var r = P.read(s.write(b), Beans.class);
+				assertSize(2, r.ins);
+				assertBean(r.ins.get(0), "name,n", "a,1");
+				assertBean(r.ins.get(1), "name,n", "b,2");
+			} catch (Exception e) { throw new AssertionError(e); }
+		});
+	}
+
+	@Test void a03_beanListSingleElement() {
+		forEachMode(s -> {
+			try {
+				var b = new Beans();
+				b.ins = List.of(new Inner("a", 1));
+				var r = P.read(s.write(b), Beans.class);
+				assertSize(1, r.ins);
+				assertBean(r.ins.get(0), "name,n", "a,1");
+			} catch (Exception e) { throw new AssertionError(e); }
+		});
+	}
+
+	@Test void a04_beanArray() {
+		forEachMode(s -> {
+			try {
+				var b = new BeanArray();
+				b.ins = new Inner[]{new Inner("a", 1), new Inner("b", 2)};
+				var r = P.read(s.write(b), BeanArray.class);
+				assertEquals(2, r.ins.length);
+				assertBean(r.ins[1], "name,n", "b,2");
+			} catch (Exception e) { throw new AssertionError(e); }
+		});
+	}
+
+	@Test void a05_map() {
+		forEachMode(s -> {
+			try {
+				var b = new Maps();
+				b.m = new LinkedHashMap<>();
+				b.m.put("x", 1);
+				b.m.put("y", 2);
+				var r = P.read(s.write(b), Maps.class);
+				assertEquals(Map.of("x", 1, "y", 2), r.m);
+			} catch (Exception e) { throw new AssertionError(e); }
+		});
+	}
+
+	@Test void a06_mapList() {
+		forEachMode(s -> {
+			try {
+				var b = new MapList();
+				b.ms = List.of(JsonMap.of("k", "v"), JsonMap.of("k", "w"));
+				var r = P.read(s.write(b), MapList.class);
+				assertSize(2, r.ms);
+				assertEquals("v", r.ms.get(0).get("k"));
+				assertEquals("w", r.ms.get(1).get("k"));
+			} catch (Exception e) { throw new AssertionError(e); }
+		});
+	}
+
+	@Test void a07_nestedLists() {
+		forEachMode(s -> {
+			try {
+				var b = new Nested();
+				b.grid = List.of(List.of(1), List.of(2, 3), List.of());
+				var proto = s.write(b);
+				assertFalse(proto.contains("\"["), proto);
+				var r = P.read(proto, Nested.class);
+				assertBean(r, "grid", "[[1],[2,3],[]]");
+			} catch (Exception e) { throw new AssertionError(e); }
+		});
+	}
+
+	@Test void a08_emptyLists() {
+		forEachMode(s -> {
+			try {
+				var b = new Empty();
+				b.a = List.of();
+				b.b = List.of();
+				b.s = "x";
+				var r = P.read(s.write(b), Empty.class);
+				assertEquals("x", r.s);
+			} catch (Exception e) { throw new AssertionError(e); }
+		});
+	}
+
+	@Test void a09_nestedMessagesInsideRepeatedMessages() {
+		forEachMode(s -> {
+			try {
+				var b = new Mid();
+				b.title = "t";
+				var b1 = new Beans();
+				b1.ins = List.of(new Inner("a", 1), new Inner("b", 2));
+				var b2 = new Beans();
+				b2.ins = List.of(new Inner("c", 3));
+				b.parts = List.of(b1, b2);
+				var r = P.read(s.write(b), Mid.class);
+				assertEquals("t", r.title);
+				assertSize(2, r.parts);
+				assertSize(2, r.parts.get(0).ins);
+				assertBean(r.parts.get(0).ins.get(1), "name,n", "b,2");
+				assertBean(r.parts.get(1).ins.get(0), "name,n", "c,3");
+			} catch (Exception e) { throw new AssertionError(e); }
+		});
+	}
+
+	@Test void a10_mixedFieldOrdering() {
+		forEachMode(s -> {
+			try {
+				var b = new Mixed();
+				b.first = new Beans();
+				b.first.ins = List.of(new Inner("z", 9));
+				b.nums = List.of(4, 5);
+				b.ins = List.of(new Inner("a", 1), new Inner("b", 2));
+				b.last = "end";
+				var r = P.read(s.write(b), Mixed.class);
+				assertBean(r.first.ins.get(0), "name,n", "z,9");
+				assertBean(r, "nums,last", "[4,5],end");
+				assertSize(2, r.ins);
+			} catch (Exception e) { throw new AssertionError(e); }
+		});
+	}
+
+	@Test void a11_erasedAndMapValueLists() {
+		forEachMode(s -> {
+			try {
+				var b = new Untyped();
+				b.items = List.of(new Inner("a", 1), new Inner("b", 2));
+				b.byKey = new LinkedHashMap<>();
+				b.byKey.put("k", List.of(new Inner("c", 3), new Inner("d", 4)));
+				b.byKey.put("j", List.of(new Inner("e", 5)));
+				var proto = s.write(b);
+				var r = P.read(proto, Untyped.class);
+				assertSize(2, (List<?>) r.items);
+				assertSize(2, r.byKey.get("k"));
+				assertBean(r.byKey.get("j").get(0), "name,n", "e,5");
+			} catch (Exception e) { throw new AssertionError(e); }
+		});
+	}
+
+	@Test void b01_compactHasSeparatorAfterClosingBrace() throws Exception {
+		var b = new Mixed();
+		b.first = new Beans();
+		b.first.ins = List.of(new Inner("z", 9));
+		b.nums = List.of(4, 5);
+		var proto = COMPACT.write(b);
+		assertFalse(proto.matches("(?s).*\\}[A-Za-z\"].*"), proto);
+	}
+
+	@Test void b02_exactOutput() throws Exception {
+		var b = new Mixed();
+		b.ins = List.of(new Inner("a", 1), new Inner("b", 2));
+		b.nums = List.of(4);
+		assertEquals("ins {n: 1\nname: \"a\"\n}\nins {n: 2\nname: \"b\"\n}\nnums: [4]\n", COMPACT.write(b));
+	}
+
+	@Test void b03_nestedListOutput() throws Exception {
+		var b = new Nested();
+		b.grid = List.of(List.of(1), List.of(2, 3), List.of());
+		assertEquals("grid {_value: [1]\n}\ngrid {_value: [2, 3]\n}\ngrid {_value: []\n}\n", COMPACT.write(b));
+	}
+}
diff --git a/juneau-core/juneau-marshall/src/test/java/org/apache/juneau/marshall/toml/TomlArrays_Test.java b/juneau-core/juneau-marshall/src/test/java/org/apache/juneau/marshall/toml/TomlArrays_Test.java
new file mode 100644
index 0000000000..ace3d5b902
--- /dev/null
+++ b/juneau-core/juneau-marshall/src/test/java/org/apache/juneau/marshall/toml/TomlArrays_Test.java
@@ -0,0 +1,172 @@
+/*
+ * 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.juneau.marshall.toml;
+
+import static org.apache.juneau.test.bct.BctAssertions.*;
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.*;
+
+import org.apache.juneau.marshall.collections.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Tests native TOML arrays and arrays-of-tables for non-scalar lists (WORK-J0594).
+ */
+class TomlArrays_Test {
+
+	private static final TomlSerializer S = TomlSerializer.create().sortKeys(true).build();
+	private static final TomlParser P = TomlParser.DEFAULT;
+
+	public static class Inner {
+		public String name;
+		public int n;
+		public Inner() {}
+		public Inner(String name, int n) { this.name = name; this.n = n; }
+	}
+
+	public static class Scalars {
+		public List<Integer> nums;
+	}
+
+	public static class Beans {
+		public List<Inner> ins;
+	}
+
+	public static class Maps {
+		public List<Map<String,Object>> ms;
+	}
+
+	public static class Nested {
+		public List<List<Integer>> grid;
+	}
+
+	public static class Empty {
+		public List<Integer> a;
+		public List<Inner> b;
+	}
+
+	public static class Outer {
+		public String title;
+		public Beans child;
+	}
+
+	public static class Mixed {
+		public List<Object> items;
+	}
+
+	@Test void a01_scalarList() throws Exception {
+		var b = new Scalars();
+		b.nums = List.of(1, 2);
+		var toml = S.write(b);
+		assertEquals("nums = [1, 2]\n", toml);
+		var r = P.read(toml, Scalars.class);
+		assertBean(r, "nums", "[1,2]");
+	}
+
+	@Test void a02_beanList() throws Exception {
+		var b = new Beans();
+		b.ins = List.of(new Inner("a", 1), new Inner("b", 2));
+		var toml = S.write(b);
+		assertTrue(toml.contains("[[ins]]\nn = 1\nname = \"a\""), toml);
+		var r = P.read(toml, Beans.class);
+		assertSize(2, r.ins);
+		assertBean(r.ins.get(0), "name,n", "a,1");
+		assertBean(r.ins.get(1), "name,n", "b,2");
+	}
+
+	@Test void a03_mapList() throws Exception {
+		var b = new Maps();
+		b.ms = List.of(JsonMap.of("k", "v"), JsonMap.of("k", "w"));
+		var toml = S.write(b);
+		assertTrue(toml.contains("[[ms]]\nk = \"v\"\n\n[[ms]]\nk = \"w\""), toml);
+		var r = P.read(toml, Maps.class);
+		assertEquals(2, r.ms.size());
+		assertEquals("v", r.ms.get(0).get("k"));
+		assertEquals("w", r.ms.get(1).get("k"));
+	}
+
+	@Test void a04_nestedArrays() throws Exception {
+		var b = new Nested();
+		b.grid = List.of(List.of(1), List.of(2, 3));
+		var toml = S.write(b);
+		assertEquals("grid = [[1], [2, 3]]\n", toml);
+		var r = P.read(toml, Nested.class);
+		assertBean(r, "grid", "[[1],[2,3]]");
+	}
+
+	@Test void a05_emptyLists() throws Exception {
+		var b = new Empty();
+		b.a = List.of();
+		b.b = List.of();
+		var toml = S.write(b);
+		assertEquals("a = []\nb = []\n", toml);
+		var r = P.read(toml, Empty.class);
+		assertEmpty(r.a);
+		assertEmpty(r.b);
+	}
+
+	@Test void a06_listInNestedTable() throws Exception {
+		var o = new Outer();
+		o.title = "t";
+		o.child = new Beans();
+		o.child.ins = List.of(new Inner("a", 1), new Inner("b", 2));
+		var toml = S.write(o);
+		assertTrue(toml.contains("[[child.ins]]"), toml);
+		var r = P.read(toml, Outer.class);
+		assertEquals("t", r.title);
+		assertSize(2, r.child.ins);
+		assertBean(r.child.ins.get(0), "name,n", "a,1");
+		assertBean(r.child.ins.get(1), "name,n", "b,2");
+	}
+
+	@Test void a07_mixedList() throws Exception {
+		var b = new Mixed();
+		b.items = List.of(1, "two", List.of(3));
+		var toml = S.write(b);
+		assertEquals("items = [1, \"two\", [3]]\n", toml);
+		var r = P.read(toml, Mixed.class);
+		assertEquals(3, r.items.size());
+		assertEquals(1, ((Number)r.items.get(0)).intValue());
+		assertEquals("two", r.items.get(1));
+		assertEquals(List.of(3L), r.items.get(2));
+	}
+
+	@Test void a08_rawMapListOfLists() throws Exception {
+		var m = JsonMap.of("nums", List.of(1, 2), "pairs", List.of(List.of("a", "b"), List.of("c")));
+		var toml = S.write(m);
+		assertTrue(toml.contains("nums = [1, 2]"), toml);
+		assertTrue(toml.contains("pairs = [[\"a\", \"b\"], [\"c\"]]"), toml);
+		var r = P.read(toml, JsonMap.class);
+		assertList(r.getList("nums"), 1L, 2L);
+		assertList(r.getList("pairs"), List.of("a", "b"), List.of("c"));
+	}
+
+	@Test void a09_rawMapListOfMaps() throws Exception {
+		var m = JsonMap.of("ins", List.of(JsonMap.of("n", 1), JsonMap.of("n", 2)));
+		var toml = S.write(m);
+		assertTrue(toml.contains("[[ins]]\nn = 1\n\n[[ins]]\nn = 2"), toml);
+		var r = P.read(toml, JsonMap.class);
+		assertEquals(2, r.getList("ins").size());
+	}
+
+	@Test void a10_beanArray() throws Exception {
+		var toml = S.write(Map.of("ins", new Inner[]{new Inner("a", 1)}));
+		var r = P.read(toml, JsonMap.class);
+		assertEquals(1, r.getList("ins").size());
+	}
+}
diff --git a/juneau-examples/juneau-examples-mcp/src/test/java/org/apache/juneau/examples/mcp/ExampleMcpEndToEnd_Test.java b/juneau-examples/juneau-examples-mcp/src/test/java/org/apache/juneau/examples/mcp/ExampleMcpEndToEnd_Test.java
index 13c1c39f97..8306bf437e 100644
--- a/juneau-examples/juneau-examples-mcp/src/test/java/org/apache/juneau/examples/mcp/ExampleMcpEndToEnd_Test.java
+++ b/juneau-examples/juneau-examples-mcp/src/test/java/org/apache/juneau/examples/mcp/ExampleMcpEndToEnd_Test.java
@@ -19,6 +19,7 @@ package org.apache.juneau.examples.mcp;
 import static org.apache.juneau.test.bct.BctAssertions.*;
 import static org.junit.jupiter.api.Assertions.*;
 
+import java.time.*;
 import java.util.*;
 import java.util.concurrent.*;
 
@@ -53,8 +54,14 @@ class ExampleMcpEndToEnd_Test extends TestBase {
 
 	@AfterAll
 	static void stopServer() throws Exception {
-		if (server != null)
-			server.close();
+		if (server == null)
+			return;
+		var start = System.nanoTime();
+		server.close();
+		var elapsed = Duration.ofNanos(System.nanoTime() - start);
+		// Regression guard (WORK-J0603): a lingering subscriptions/listen stream used to hold Jetty's graceful
+		// drain open for its full 30s stopTimeout.  Shutdown must now end open streams and return promptly.
+		assertTrue(elapsed.compareTo(Duration.ofSeconds(10)) < 0, "Server shutdown took " + elapsed + "; open SSE streams are blocking the graceful drain.");
 	}
 
 	// A fresh client per test avoids reusing a pooled keep-alive connection that the server may have closed while
diff --git a/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/util/BoundedServletInputStream_Test.java b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/util/BoundedServletInputStream_Test.java
index 96c141a106..c21b1fc5b7 100644
--- a/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/util/BoundedServletInputStream_Test.java
+++ b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/util/BoundedServletInputStream_Test.java
@@ -141,6 +141,40 @@ class BoundedServletInputStream_Test extends TestBase {
 		}
 	}
 
+	@Test void c05_readBuf_exactBound_thenEof() throws Exception {
+		try (var in = new BoundedServletInputStream(new ByteArrayInputStream("abc".getBytes()), 3)) {
+			var buf = new byte[10];
+			assertEquals(3, in.read(buf, 0, 10));
+			assertEquals(-1, in.read(buf, 0, 10));
+			assertEquals(-1, in.read(buf));
+			assertEquals(-1, in.read());
+			assertEquals(0, in.read(buf, 0, 0));
+		}
+	}
+
+	@Test void c06_readBuf_overRead_throws() throws Exception {
+		try (var in = new BoundedServletInputStream(new ByteArrayInputStream("abcdef".getBytes()), 3)) {
+			var buf = new byte[10];
+			assertEquals(3, in.read(buf, 0, 10));
+			assertThrows(IOException.class, () -> in.read(buf, 0, 10));
+		}
+	}
+
+	@Test void c07_emptyBody_zeroLimit_returnsEof() throws Exception {
+		try (var in = new BoundedServletInputStream(new ByteArrayInputStream(new byte[0]), 0)) {
+			assertEquals(-1, in.read(new byte[10]));
+			assertEquals(-1, in.read());
+		}
+	}
+
+	@Test void c08_read_exactBound_singleByteThenEof() throws Exception {
+		try (var in = new BoundedServletInputStream(new ByteArrayInputStream("ab".getBytes()), 2)) {
+			assertEquals('a', in.read());
+			assertEquals('b', in.read());
+			assertEquals(-1, in.read());
+		}
+	}
+
 	@Test void c04_readBuf_overLimit_throws() throws Exception {
 		// With remain<0 enforcement, asking for more than remain gets clamped, but if a subsequent
 		// read goes negative we expect an IOException via decrement(numBytes).
diff --git a/juneau-microservice/juneau-microservice-jetty/src/main/java/org/apache/juneau/microservice/jetty/JettyServerComponent.java b/juneau-microservice/juneau-microservice-jetty/src/main/java/org/apache/juneau/microservice/jetty/JettyServerComponent.java
index c7d941e3c8..fbdb6dd46c 100644
--- a/juneau-microservice/juneau-microservice-jetty/src/main/java/org/apache/juneau/microservice/jetty/JettyServerComponent.java
+++ b/juneau-microservice/juneau-microservice-jetty/src/main/java/org/apache/juneau/microservice/jetty/JettyServerComponent.java
@@ -323,6 +323,10 @@ public class JettyServerComponent implements MicroserviceListener {
 					// ms.getBeanStore(), so the lifecycle-owned instance must be handed to it explicitly before
 					// it initializes; HealthServlet.initReadinessState() registers it into its own bean store.
 					hs.publishReadinessState(readinessState);
+				if (servlet instanceof ReadinessStateAware rsa)
+					// Lets servlets holding long-lived streams (e.g. MCP subscriptions/listen) end them when shutdown
+					// begins, so the graceful drain does not wait out the full stopTimeout for them.
+					rsa.acceptReadinessState(readinessState);
 				var pathSpecs = restPathsFor(servlet, store);
 				var source = "@Bean " + cls.getName() + (ine(e.getKey()) ? "[" + e.getKey() + "]" : "");
 				for (var pathSpec : pathSpecs)
diff --git a/juneau-microservice/juneau-microservice-tomcat/src/main/java/org/apache/juneau/microservice/tomcat/TomcatServerComponent.java b/juneau-microservice/juneau-microservice-tomcat/src/main/java/org/apache/juneau/microservice/tomcat/TomcatServerComponent.java
index 3b282f49ce..c5c691e98f 100644
--- a/juneau-microservice/juneau-microservice-tomcat/src/main/java/org/apache/juneau/microservice/tomcat/TomcatServerComponent.java
+++ b/juneau-microservice/juneau-microservice-tomcat/src/main/java/org/apache/juneau/microservice/tomcat/TomcatServerComponent.java
@@ -282,6 +282,10 @@ public class TomcatServerComponent implements MicroserviceListener {
 					// ms.getBeanStore(), so the lifecycle-owned instance must be handed to it explicitly before
 					// it initializes; HealthServlet.initReadinessState() registers it into its own bean store.
 					hs.publishReadinessState(readinessState);
+				if (servlet instanceof ReadinessStateAware rsa)
+					// Lets servlets holding long-lived streams (e.g. MCP subscriptions/listen) end them when shutdown
+					// begins, so the graceful drain does not wait out the full stopTimeout for them.
+					rsa.acceptReadinessState(readinessState);
 				var pathSpecs = restPathsFor(servlet, store);
 				var source = "@Bean " + cls.getName() + (ine(e.getKey()) ? "[" + e.getKey() + "]" : "");
 				for (var pathSpec : pathSpecs)
diff --git a/juneau-rest/juneau-rest-client-apache-httpclient-45/src/main/java/org/apache/juneau/rest/client/apachehttpclient45/ApacheHc45Transport.java b/juneau-rest/juneau-rest-client-apache-httpclient-45/src/main/java/org/apache/juneau/rest/client/apachehttpclient45/ApacheHc45Transport.java
index 382d482cf5..fca48d4b51 100644
--- a/juneau-rest/juneau-rest-client-apache-httpclient-45/src/main/java/org/apache/juneau/rest/client/apachehttpclient45/ApacheHc45Transport.java
+++ b/juneau-rest/juneau-rest-client-apache-httpclient-45/src/main/java/org/apache/juneau/rest/client/apachehttpclient45/ApacheHc45Transport.java
@@ -21,6 +21,7 @@ import java.net.*;
 import java.util.*;
 
 import org.apache.http.*;
+import org.apache.http.client.config.*;
 import org.apache.http.client.methods.*;
 import org.apache.http.config.*;
 import org.apache.http.conn.socket.*;
@@ -205,10 +206,26 @@ public final class ApacheHc45Transport implements HttpTransport {
 	// Internal helpers
 	// -----------------------------------------------------------------------------------------------------------------
 
-	private static HttpUriRequest buildHcRequest(TransportRequest request) throws TransportException {
+	// The effective default config to layer the per-request timeouts on: the request's own config if any, else the
+	// client's default, else the HttpClient defaults -- so client-level settings (redirects, cookie spec, ...) survive.
+	private RequestConfig baseConfig(RequestBuilder builder) {
+		if (builder.getConfig() != null)
+			return builder.getConfig();
+		if (httpClient instanceof Configurable c && c.getConfig() != null)
+			return c.getConfig();
+		return RequestConfig.DEFAULT;
+	}
+
+	private HttpUriRequest buildHcRequest(TransportRequest request) throws TransportException {
 		var builder = RequestBuilder.create(request.getMethod()).setUri(request.getUri());
 		for (var h : request.getHeaders())
 			builder.addHeader(h.name(), h.value());
+		var timeout = request.getTimeout();
+		if (timeout != null) {
+			// Per-request socket (read) and connect timeouts; overrides the client's default RequestConfig for this call only.
+			var ms = (int)Math.min(Integer.MAX_VALUE, Math.max(1, timeout.toMillis()));
+			builder.setConfig(RequestConfig.copy(baseConfig(builder)).setSocketTimeout(ms).setConnectTimeout(ms).build());
+		}
 		var body = request.getBody();
 		if (body != null)
 			builder.setEntity(new TransportBodyEntity(body));
diff --git a/juneau-rest/juneau-rest-client-apache-httpclient-45/src/test/java/org/apache/juneau/rest/client/ApacheHc45Transport_RedirectCredentials_Test.java b/juneau-rest/juneau-rest-client-apache-httpclient-45/src/test/java/org/apache/juneau/rest/client/ApacheHc45Transport_RedirectCredentials_Test.java
index 970a73ba72..fb89793683 100644
--- a/juneau-rest/juneau-rest-client-apache-httpclient-45/src/test/java/org/apache/juneau/rest/client/ApacheHc45Transport_RedirectCredentials_Test.java
+++ b/juneau-rest/juneau-rest-client-apache-httpclient-45/src/test/java/org/apache/juneau/rest/client/ApacheHc45Transport_RedirectCredentials_Test.java
@@ -35,6 +35,8 @@ import com.sun.net.httpserver.*;
 @SuppressWarnings({
 	"resource" // Transport/client instances are short-lived test fixtures.
 })
+// SEPARATE_THREAD: a socket read blocked on a non-responding peer ignores interrupts, so SAME_THREAD would still hang.
+@Timeout(value = 60, threadMode = Timeout.ThreadMode.SEPARATE_THREAD)
 class ApacheHc45Transport_RedirectCredentials_Test {
 
 	private static HttpServer serverA;
@@ -44,16 +46,18 @@ class ApacheHc45Transport_RedirectCredentials_Test {
 
 	@BeforeAll
 	static void startServers() throws IOException {
-		serverB = HttpServer.create(new InetSocketAddress(0), 0);
+		// Bind to (and connect via) the explicit IPv4 loopback, not the wildcard address + "localhost": a wildcard bind can be
+		// handed an ephemeral port another process already holds on 127.0.0.1 (SO_REUSEADDR), which then wins every "localhost" connection.
+		serverB = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
 		portB = serverB.getAddress().getPort();
 		serverB.createContext("/echo-creds", ApacheHc45Transport_RedirectCredentials_Test::echoCreds);
 		serverB.start();
 
-		serverA = HttpServer.create(new InetSocketAddress(0), 0);
+		serverA = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
 		portA = serverA.getAddress().getPort();
 		serverA.createContext("/echo-creds", ApacheHc45Transport_RedirectCredentials_Test::echoCreds);
-		serverA.createContext("/redirect-cross", exchange -> redirect(exchange, "http://localhost:" + portB + "/echo-creds"));
-		serverA.createContext("/redirect-same", exchange -> redirect(exchange, "http://localhost:" + portA + "/echo-creds"));
+		serverA.createContext("/redirect-cross", exchange -> redirect(exchange, "http://127.0.0.1:" + portB + "/echo-creds"));
+		serverA.createContext("/redirect-same", exchange -> redirect(exchange, "http://127.0.0.1:" + portA + "/echo-creds"));
 		serverA.start();
 	}
 
@@ -85,7 +89,7 @@ class ApacheHc45Transport_RedirectCredentials_Test {
 	@Test
 	void a01_crossOrigin_stripsCredentials() throws Exception {
 		var transport = ApacheHc45Transport.create();
-		try (var client = RestClient.builder().transport(transport).rootUrl("http://localhost:" + portA).build()) {
+		try (var client = RestClient.builder().transport(transport).rootUrl("http://127.0.0.1:" + portA).build()) {
 			try (var response = client.get("/redirect-cross")
 					.header("Authorization", "Bearer secret-token")
 					.header("Cookie", "session=abc123")
@@ -99,7 +103,7 @@ class ApacheHc45Transport_RedirectCredentials_Test {
 	@Test
 	void a02_sameOrigin_forwardsCredentials() throws Exception {
 		var transport = ApacheHc45Transport.create();
-		try (var client = RestClient.builder().transport(transport).rootUrl("http://localhost:" + portA).build()) {
+		try (var client = RestClient.builder().transport(transport).rootUrl("http://127.0.0.1:" + portA).build()) {
 			try (var response = client.get("/redirect-same")
 					.header("Authorization", "Bearer secret-token")
 					.run()) {
diff --git a/juneau-rest/juneau-rest-client-apache-httpclient-45/src/test/java/org/apache/juneau/rest/client/ApacheHc45Transport_StaleConnectionRetry_Test.java b/juneau-rest/juneau-rest-client-apache-httpclient-45/src/test/java/org/apache/juneau/rest/client/ApacheHc45Transport_StaleConnectionRetry_Test.java
index 2f9dda0cb6..45cb6f41aa 100644
--- a/juneau-rest/juneau-rest-client-apache-httpclient-45/src/test/java/org/apache/juneau/rest/client/ApacheHc45Transport_StaleConnectionRetry_Test.java
+++ b/juneau-rest/juneau-rest-client-apache-httpclient-45/src/test/java/org/apache/juneau/rest/client/ApacheHc45Transport_StaleConnectionRetry_Test.java
@@ -41,6 +41,9 @@ import org.junit.jupiter.api.*;
 @SuppressWarnings({
 	"resource" // Transport/client instances are short-lived test fixtures.
 })
+// SEPARATE_THREAD: a socket read blocked on a non-responding peer ignores interrupts, so SAME_THREAD would still hang.
+// Fixtures bind/connect via explicit 127.0.0.1: a wildcard bind can be handed a port another process holds on 127.0.0.1.
+@Timeout(value = 60, threadMode = Timeout.ThreadMode.SEPARATE_THREAD)
 class ApacheHc45Transport_StaleConnectionRetry_Test {
 
 	// =================================================================================================================
@@ -91,14 +94,14 @@ class ApacheHc45Transport_StaleConnectionRetry_Test {
 
 		A01_StaleServer(int staleConnections) throws IOException {
 			this.staleConnections = staleConnections;
-			this.serverSocket = new ServerSocket(0);
+			this.serverSocket = new ServerSocket(0, 50, InetAddress.getByName("127.0.0.1"));
 			var acceptThread = new Thread(this::acceptLoop, "hc45-stale-server");
 			acceptThread.setDaemon(true);
 			acceptThread.start();
 		}
 
 		String rootUrl() {
-			return "http://localhost:" + serverSocket.getLocalPort();
+			return "http://127.0.0.1:" + serverSocket.getLocalPort();
 		}
 
 		int connectionCount() {
diff --git a/juneau-rest/juneau-rest-client-apache-httpclient-45/src/test/java/org/apache/juneau/rest/client/ApacheHc45Transport_Test.java b/juneau-rest/juneau-rest-client-apache-httpclient-45/src/test/java/org/apache/juneau/rest/client/ApacheHc45Transport_Test.java
index 75aad258f0..d9a100f6c8 100644
--- a/juneau-rest/juneau-rest-client-apache-httpclient-45/src/test/java/org/apache/juneau/rest/client/ApacheHc45Transport_Test.java
+++ b/juneau-rest/juneau-rest-client-apache-httpclient-45/src/test/java/org/apache/juneau/rest/client/ApacheHc45Transport_Test.java
@@ -34,6 +34,8 @@ import com.sun.net.httpserver.*;
 @SuppressWarnings({
 	"resource" // Transport instances are short-lived test fixtures; closing is irrelevant to these assertions.
 })
+// SEPARATE_THREAD: a socket read blocked on a non-responding peer ignores interrupts, so SAME_THREAD would still hang.
+@Timeout(value = 60, threadMode = Timeout.ThreadMode.SEPARATE_THREAD)
 class ApacheHc45Transport_Test {
 
 	private static HttpServer server;
@@ -41,7 +43,9 @@ class ApacheHc45Transport_Test {
 
 	@BeforeAll
 	static void startServer() throws IOException {
-		server = HttpServer.create(new InetSocketAddress(0), 0);
+		// Bind to (and connect via) the explicit IPv4 loopback, not the wildcard address + "localhost": a wildcard bind can be
+		// handed an ephemeral port another process already holds on 127.0.0.1 (SO_REUSEADDR), which then wins every "localhost" connection.
+		server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
 		port = server.getAddress().getPort();
 
 		server.createContext("/hello", exchange -> {
@@ -97,7 +101,7 @@ class ApacheHc45Transport_Test {
 	}
 
 	private String rootUrl() {
-		return "http://localhost:" + port;
+		return "http://127.0.0.1:" + port;
 	}
 
 	// =================================================================================================================
diff --git a/juneau-rest/juneau-rest-client-apache-httpclient-45/src/test/java/org/apache/juneau/rest/client/ApacheHc45Transport_Timeout_Test.java b/juneau-rest/juneau-rest-client-apache-httpclient-45/src/test/java/org/apache/juneau/rest/client/ApacheHc45Transport_Timeout_Test.java
new file mode 100644
index 0000000000..9708a740a6
--- /dev/null
+++ b/juneau-rest/juneau-rest-client-apache-httpclient-45/src/test/java/org/apache/juneau/rest/client/ApacheHc45Transport_Timeout_Test.java
@@ -0,0 +1,125 @@
+/*
+ * 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.juneau.rest.client;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.io.*;
+import java.net.*;
+import java.nio.charset.*;
+import java.time.*;
+
+import org.apache.juneau.rest.client.*;
+import org.apache.juneau.rest.client.apachehttpclient45.*;
+import org.junit.jupiter.api.*;
+
+import com.sun.net.httpserver.*;
+
+/**
+ * Verifies that {@link TransportRequest#getTimeout()} is honored per request by {@code ApacheHc45Transport} (WORK-J0596).
+ *
+ * <p>
+ * Uses an embedded server whose {@code /slow} endpoint delays 2s before responding.
+ */
+@SuppressWarnings({
+	"java:S2925", // The server handler sleeps to simulate a slow upstream.
+	"resource" // Transport/response instances are short-lived test fixtures.
+})
+// SEPARATE_THREAD: a socket read blocked on a non-responding peer ignores interrupts, so SAME_THREAD would still hang.
+@Timeout(value = 60, threadMode = Timeout.ThreadMode.SEPARATE_THREAD)
+class ApacheHc45Transport_Timeout_Test {
+
+	private static HttpServer server;
+	private static int port;
+
+	@BeforeAll
+	static void startServer() throws IOException {
+		// Bind to (and connect via) the explicit IPv4 loopback, not the wildcard address + "localhost": a wildcard bind can be
+		// handed an ephemeral port another process already holds on 127.0.0.1 (SO_REUSEADDR), which then wins every "localhost" connection.
+		server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+		port = server.getAddress().getPort();
+		server.createContext("/slow", exchange -> {
+			try {
+				Thread.sleep(2000);
+				var body = "ok".getBytes(StandardCharsets.UTF_8);
+				exchange.sendResponseHeaders(200, body.length);
+				exchange.getResponseBody().write(body);
+			} catch (InterruptedException | IOException e) {
+				// Client gave up; nothing to do.
+			} finally {
+				exchange.close();
+			}
+		});
+		server.createContext("/redirect", exchange -> {
+			exchange.getResponseHeaders().add("Location", "/fast");
+			exchange.sendResponseHeaders(302, -1);
+			exchange.close();
+		});
+		server.createContext("/fast", exchange -> {
+			var body = "ok".getBytes(StandardCharsets.UTF_8);
+			exchange.sendResponseHeaders(200, body.length);
+			exchange.getResponseBody().write(body);
+			exchange.close();
+		});
+		server.start();
+	}
+
+	@AfterAll
+	static void stopServer() {
+		server.stop(0);
+	}
+
+	private static TransportRequest request(String path, Duration timeout) {
+		return TransportRequest.builder().method("GET").uri("http://127.0.0.1:" + port + path).timeout(timeout).build();
+	}
+
+	@Test void a01_shortTimeout_timesOutQuickly() throws Exception {
+		try (var transport = ApacheHc45Transport.create()) {
+			var start = System.nanoTime();
+			var e = assertThrows(TransportException.class, () -> transport.execute(request("/slow", Duration.ofMillis(200))));
+			assertTrue(Duration.ofNanos(System.nanoTime() - start).toMillis() < 1800, "should time out well before the 2s server delay");
+			assertNotNull(e.getCause());
+		}
+	}
+
+	@Test void a02_noTimeout_waitsForResponse() throws Exception {
+		try (var transport = ApacheHc45Transport.create(); var response = transport.execute(request("/slow", null))) {
+			assertEquals(200, response.getStatusCode());
+		}
+	}
+
+	@Test void a03_longTimeout_succeeds() throws Exception {
+		try (var transport = ApacheHc45Transport.create(); var response = transport.execute(request("/slow", Duration.ofSeconds(10)))) {
+			assertEquals(200, response.getStatusCode());
+		}
+	}
+
+	@Test void a05_clientLevelRequestConfig_survivesPerRequestTimeout() throws Exception {
+		// The client disables redirects; a per-request timeout must not reset that to the HttpClient default (enabled).
+		var client = org.apache.http.impl.client.HttpClients.custom().setDefaultRequestConfig(org.apache.http.client.config.RequestConfig.custom().setRedirectsEnabled(false).build()).build();
+		try (var transport = ApacheHc45Transport.builder().httpClient(client).build();
+			var response = transport.execute(request("/redirect", Duration.ofSeconds(10)))) {
+			assertEquals(302, response.getStatusCode());
+		}
+	}
+
+	@Test void a04_shortTimeout_fastEndpoint_succeeds() throws Exception {
+		try (var transport = ApacheHc45Transport.create(); var response = transport.execute(request("/fast", Duration.ofMillis(200)))) {
+			assertEquals(200, response.getStatusCode());
+		}
+	}
+}
diff --git a/juneau-rest/juneau-rest-client-apache-httpclient-50/src/main/java/org/apache/juneau/rest/client/apachehttpclient50/ApacheHc5Transport.java b/juneau-rest/juneau-rest-client-apache-httpclient-50/src/main/java/org/apache/juneau/rest/client/apachehttpclient50/ApacheHc5Transport.java
index b2d6fea7ca..7bef5e02e6 100644
--- a/juneau-rest/juneau-rest-client-apache-httpclient-50/src/main/java/org/apache/juneau/rest/client/apachehttpclient50/ApacheHc5Transport.java
+++ b/juneau-rest/juneau-rest-client-apache-httpclient-50/src/main/java/org/apache/juneau/rest/client/apachehttpclient50/ApacheHc5Transport.java
@@ -18,12 +18,15 @@ package org.apache.juneau.rest.client.apachehttpclient50;
 
 import java.io.*;
 
+import org.apache.hc.client5.http.config.*;
 import org.apache.hc.client5.http.impl.classic.*;
+import org.apache.hc.client5.http.protocol.*;
 import org.apache.hc.client5.http.impl.io.*;
 import org.apache.hc.core5.http.*;
 import org.apache.hc.core5.http.io.entity.*;
 import org.apache.hc.core5.http.io.support.*;
 import org.apache.hc.core5.http.message.*;
+import org.apache.hc.core5.util.*;
 import org.apache.juneau.http.*;
 import org.apache.juneau.rest.client.*;
 
@@ -132,7 +135,7 @@ public final class ApacheHc5Transport implements HttpTransport {
 		var hcRequest = buildHcRequest(request);
 		ClassicHttpResponse hcResponse;
 		try {
-			hcResponse = httpClient.executeOpen(null, hcRequest, null);
+			hcResponse = httpClient.executeOpen(null, hcRequest, buildContext(request));
 		} catch (IOException e) {
 			throw new TransportException("HTTP transport error: " + e.getMessage(), e);
 		}
@@ -162,6 +165,18 @@ public final class ApacheHc5Transport implements HttpTransport {
 	// Internal helpers
 	// -----------------------------------------------------------------------------------------------------------------
 
+	// Per-request response (socket read) timeout via a request-scoped RequestConfig; null context when no timeout is set.
+	private HttpClientContext buildContext(TransportRequest request) {
+		var timeout = request.getTimeout();
+		if (timeout == null)
+			return null;
+		var context = HttpClientContext.create();
+		// A context-level RequestConfig replaces (not merges with) the client default, so copy the client's default first.
+		var base = httpClient instanceof Configurable c && c.getConfig() != null ? c.getConfig() : RequestConfig.DEFAULT;
+		context.setRequestConfig(RequestConfig.copy(base).setResponseTimeout(Timeout.ofMilliseconds(Math.max(1, timeout.toMillis()))).build());
+		return context;
+	}
+
 	private static ClassicHttpRequest buildHcRequest(TransportRequest request) throws TransportException {
 		var builder = ClassicRequestBuilder.create(request.getMethod()).setUri(request.getUri());
 		for (var h : request.getHeaders())
diff --git a/juneau-rest/juneau-rest-client-apache-httpclient-50/src/test/java/org/apache/juneau/rest/client/ApacheHc5Transport_Test.java b/juneau-rest/juneau-rest-client-apache-httpclient-50/src/test/java/org/apache/juneau/rest/client/ApacheHc5Transport_Test.java
index 427e16326b..cbf5143f3a 100644
--- a/juneau-rest/juneau-rest-client-apache-httpclient-50/src/test/java/org/apache/juneau/rest/client/ApacheHc5Transport_Test.java
+++ b/juneau-rest/juneau-rest-client-apache-httpclient-50/src/test/java/org/apache/juneau/rest/client/ApacheHc5Transport_Test.java
@@ -34,6 +34,8 @@ import com.sun.net.httpserver.*;
 @SuppressWarnings({
 	"resource" // Transport/client instances are short-lived test fixtures; closing is irrelevant to these assertions.
 })
+// SEPARATE_THREAD: a socket read blocked on a non-responding peer ignores interrupts, so SAME_THREAD would still hang.
+@Timeout(value = 60, threadMode = Timeout.ThreadMode.SEPARATE_THREAD)
 class ApacheHc5Transport_Test {
 
 	private static HttpServer server;
@@ -41,7 +43,9 @@ class ApacheHc5Transport_Test {
 
 	@BeforeAll
 	static void startServer() throws IOException {
-		server = HttpServer.create(new InetSocketAddress(0), 0);
+		// Bind to (and connect via) the explicit IPv4 loopback, not the wildcard address + "localhost": a wildcard bind can be
+		// handed an ephemeral port another process already holds on 127.0.0.1 (SO_REUSEADDR), which then wins every "localhost" connection.
+		server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
 		port = server.getAddress().getPort();
 
 		server.createContext("/hello", exchange -> {
@@ -97,7 +101,7 @@ class ApacheHc5Transport_Test {
 	}
 
 	private String rootUrl() {
-		return "http://localhost:" + port;
+		return "http://127.0.0.1:" + port;
 	}
 
 	// =================================================================================================================
diff --git a/juneau-rest/juneau-rest-client-apache-httpclient-50/src/test/java/org/apache/juneau/rest/client/ApacheHc5Transport_Timeout_Test.java b/juneau-rest/juneau-rest-client-apache-httpclient-50/src/test/java/org/apache/juneau/rest/client/ApacheHc5Transport_Timeout_Test.java
new file mode 100644
index 0000000000..7604aa46ce
--- /dev/null
+++ b/juneau-rest/juneau-rest-client-apache-httpclient-50/src/test/java/org/apache/juneau/rest/client/ApacheHc5Transport_Timeout_Test.java
@@ -0,0 +1,125 @@
+/*
+ * 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.juneau.rest.client;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.io.*;
+import java.net.*;
+import java.nio.charset.*;
+import java.time.*;
+
+import org.apache.juneau.rest.client.*;
+import org.apache.juneau.rest.client.apachehttpclient50.*;
+import org.junit.jupiter.api.*;
+
+import com.sun.net.httpserver.*;
+
+/**
+ * Verifies that {@link TransportRequest#getTimeout()} is honored per request by {@code ApacheHc5Transport} (WORK-J0596).
+ *
+ * <p>
+ * Uses an embedded server whose {@code /slow} endpoint delays 2s before responding.
+ */
+@SuppressWarnings({
+	"java:S2925", // The server handler sleeps to simulate a slow upstream.
+	"resource" // Transport/response instances are short-lived test fixtures.
+})
+// SEPARATE_THREAD: a socket read blocked on a non-responding peer ignores interrupts, so SAME_THREAD would still hang.
+@Timeout(value = 60, threadMode = Timeout.ThreadMode.SEPARATE_THREAD)
+class ApacheHc5Transport_Timeout_Test {
+
+	private static HttpServer server;
+	private static int port;
+
+	@BeforeAll
+	static void startServer() throws IOException {
+		// Bind to (and connect via) the explicit IPv4 loopback, not the wildcard address + "localhost": a wildcard bind can be
+		// handed an ephemeral port another process already holds on 127.0.0.1 (SO_REUSEADDR), which then wins every "localhost" connection.
+		server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+		port = server.getAddress().getPort();
+		server.createContext("/slow", exchange -> {
+			try {
+				Thread.sleep(2000);
+				var body = "ok".getBytes(StandardCharsets.UTF_8);
+				exchange.sendResponseHeaders(200, body.length);
+				exchange.getResponseBody().write(body);
+			} catch (InterruptedException | IOException e) {
+				// Client gave up; nothing to do.
+			} finally {
+				exchange.close();
+			}
+		});
+		server.createContext("/redirect", exchange -> {
+			exchange.getResponseHeaders().add("Location", "/fast");
+			exchange.sendResponseHeaders(302, -1);
+			exchange.close();
+		});
+		server.createContext("/fast", exchange -> {
+			var body = "ok".getBytes(StandardCharsets.UTF_8);
+			exchange.sendResponseHeaders(200, body.length);
+			exchange.getResponseBody().write(body);
+			exchange.close();
+		});
+		server.start();
+	}
+
+	@AfterAll
+	static void stopServer() {
+		server.stop(0);
+	}
+
+	private static TransportRequest request(String path, Duration timeout) {
+		return TransportRequest.builder().method("GET").uri("http://127.0.0.1:" + port + path).timeout(timeout).build();
+	}
+
+	@Test void a01_shortTimeout_timesOutQuickly() throws Exception {
+		try (var transport = ApacheHc5Transport.create()) {
+			var start = System.nanoTime();
+			var e = assertThrows(TransportException.class, () -> transport.execute(request("/slow", Duration.ofMillis(200))));
+			assertTrue(Duration.ofNanos(System.nanoTime() - start).toMillis() < 1800, "should time out well before the 2s server delay");
+			assertNotNull(e.getCause());
+		}
+	}
+
+	@Test void a02_noTimeout_waitsForResponse() throws Exception {
+		try (var transport = ApacheHc5Transport.create(); var response = transport.execute(request("/slow", null))) {
+			assertEquals(200, response.getStatusCode());
+		}
+	}
+
+	@Test void a03_longTimeout_succeeds() throws Exception {
+		try (var transport = ApacheHc5Transport.create(); var response = transport.execute(request("/slow", Duration.ofSeconds(10)))) {
+			assertEquals(200, response.getStatusCode());
+		}
+	}
+
+	@Test void a05_clientLevelRequestConfig_survivesPerRequestTimeout() throws Exception {
+		// The client disables redirects; a per-request timeout must not reset that to the HttpClient default (enabled).
+		var client = org.apache.hc.client5.http.impl.classic.HttpClients.custom().setDefaultRequestConfig(org.apache.hc.client5.http.config.RequestConfig.custom().setRedirectsEnabled(false).build()).build();
+		try (var transport = ApacheHc5Transport.builder().httpClient(client).build();
+			var response = transport.execute(request("/redirect", Duration.ofSeconds(10)))) {
+			assertEquals(302, response.getStatusCode());
+		}
+	}
+
+	@Test void a04_shortTimeout_fastEndpoint_succeeds() throws Exception {
+		try (var transport = ApacheHc5Transport.create(); var response = transport.execute(request("/fast", Duration.ofMillis(200)))) {
+			assertEquals(200, response.getStatusCode());
+		}
+	}
+}
diff --git a/juneau-rest/juneau-rest-client-classic/src/test/java/org/apache/juneau/rest/client/classic/RemoteProxyParity_Test.java b/juneau-rest/juneau-rest-client-classic/src/test/java/org/apache/juneau/rest/client/classic/RemoteProxyParity_Test.java
index 4efec7afca..0af924ca40 100644
--- a/juneau-rest/juneau-rest-client-classic/src/test/java/org/apache/juneau/rest/client/classic/RemoteProxyParity_Test.java
+++ b/juneau-rest/juneau-rest-client-classic/src/test/java/org/apache/juneau/rest/client/classic/RemoteProxyParity_Test.java
@@ -43,6 +43,8 @@ import com.sun.net.httpserver.*;
 @SuppressWarnings({
 	"resource" // RestClient instances are created per-test and closed via try-with-resources where practical; a few short-lived proxies intentionally rely on JVM teardown.
 })
+// SEPARATE_THREAD: a socket read blocked on a non-responding peer ignores interrupts, so SAME_THREAD would still hang.
+@Timeout(value = 60, threadMode = Timeout.ThreadMode.SEPARATE_THREAD)
 class RemoteProxyParity_Test {
 
 	private static HttpServer server;
@@ -70,10 +72,14 @@ class RemoteProxyParity_Test {
 	})
 	@BeforeAll
 	static void startServer() throws IOException {
-		server = HttpServer.create(new InetSocketAddress(0), 0);
+		// Bind to (and connect via) the explicit IPv4 loopback, not the wildcard address + "localhost": a wildcard bind
+		// can be handed an ephemeral port that another process already holds on 127.0.0.1 (SO_REUSEADDR), and that
+		// more-specific listener then wins every "localhost" connection -- the client hangs on its response.
+		server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
 		port = server.getAddress().getPort();
 		// Without an explicit executor, exchanges run on HttpServer's single internal dispatch thread, which
-		// starves under -T1C reactor-level parallel test load and can fail with "server failed to respond".
+		// is a bottleneck under -T1C parallel load. (The "server failed to respond" flake once blamed on starvation was most likely
+		// the wildcard-bind port shadowing fixed by binding 127.0.0.1 above; the executor is kept as harmless.)
 		executor = Executors.newCachedThreadPool();
 		server.setExecutor(executor);
 		server.createContext("/", exchange -> {
@@ -124,7 +130,7 @@ class RemoteProxyParity_Test {
 	}
 
 	private static String url() {
-		return "http://localhost:" + port;
+		return "http://127.0.0.1:" + port;
 	}
 
 	//------------------------------------------------------------------------------------------------------------------
diff --git a/juneau-rest/juneau-rest-client-classic/src/test/java/org/apache/juneau/rest/client/classic/RemoteProxyThrowOnError_Test.java b/juneau-rest/juneau-rest-client-classic/src/test/java/org/apache/juneau/rest/client/classic/RemoteProxyThrowOnError_Test.java
index 7957e89166..7729890f4c 100644
--- a/juneau-rest/juneau-rest-client-classic/src/test/java/org/apache/juneau/rest/client/classic/RemoteProxyThrowOnError_Test.java
+++ b/juneau-rest/juneau-rest-client-classic/src/test/java/org/apache/juneau/rest/client/classic/RemoteProxyThrowOnError_Test.java
@@ -46,6 +46,8 @@ import com.sun.net.httpserver.*;
 @SuppressWarnings({
 	"resource" // RestClient instances are created per-test and closed via try-with-resources.
 })
+// SEPARATE_THREAD: a socket read blocked on a non-responding peer ignores interrupts, so SAME_THREAD would still hang.
+@Timeout(value = 60, threadMode = Timeout.ThreadMode.SEPARATE_THREAD)
 class RemoteProxyThrowOnError_Test {
 
 	private static HttpServer server;
@@ -56,10 +58,14 @@ class RemoteProxyThrowOnError_Test {
 
 	@BeforeAll
 	static void startServer() throws IOException {
-		server = HttpServer.create(new InetSocketAddress(0), 0);
+		// Bind to (and connect via) the explicit IPv4 loopback, not the wildcard address + "localhost": a wildcard bind
+		// can be handed an ephemeral port that another process already holds on 127.0.0.1 (SO_REUSEADDR), and that
+		// more-specific listener then wins every "localhost" connection -- the client hangs on its response.
+		server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
 		port = server.getAddress().getPort();
 		// Without an explicit executor, exchanges run on HttpServer's single internal dispatch thread, which
-		// starves under -T1C reactor-level parallel test load and can fail with "server failed to respond".
+		// is a bottleneck under -T1C parallel load. (The "server failed to respond" flake once blamed on starvation was most likely
+		// the wildcard-bind port shadowing fixed by binding 127.0.0.1 above; the executor is kept as harmless.)
 		executor = Executors.newCachedThreadPool();
 		server.setExecutor(executor);
 		server.createContext("/", exchange -> {
@@ -89,7 +95,7 @@ class RemoteProxyThrowOnError_Test {
 	}
 
 	private static String url() {
-		return "http://localhost:" + port;
+		return "http://127.0.0.1:" + port;
 	}
 
 	//------------------------------------------------------------------------------------------------------------------
diff --git a/juneau-rest/juneau-rest-client-classic/src/test/java/org/apache/juneau/rest/client/classic/ResponseContent_As_Coverage_Test.java b/juneau-rest/juneau-rest-client-classic/src/test/java/org/apache/juneau/rest/client/classic/ResponseContent_As_Coverage_Test.java
index eec873da07..af86a7a422 100644
--- a/juneau-rest/juneau-rest-client-classic/src/test/java/org/apache/juneau/rest/client/classic/ResponseContent_As_Coverage_Test.java
+++ b/juneau-rest/juneau-rest-client-classic/src/test/java/org/apache/juneau/rest/client/classic/ResponseContent_As_Coverage_Test.java
@@ -38,6 +38,8 @@ import com.sun.net.httpserver.*;
 @SuppressWarnings({
 	"resource" // Closeable resources in tests are intentionally unassigned; closing is handled by test infrastructure.
 })
+// SEPARATE_THREAD: a socket read blocked on a non-responding peer ignores interrupts, so SAME_THREAD would still hang.
+@Timeout(value = 60, threadMode = Timeout.ThreadMode.SEPARATE_THREAD)
 class ResponseContent_As_Coverage_Test {
 
 	private static HttpServer server;
@@ -46,10 +48,14 @@ class ResponseContent_As_Coverage_Test {
 
 	@BeforeAll
 	static void startServer() throws IOException {
-		server = HttpServer.create(new InetSocketAddress(0), 0);
+		// Bind to (and connect via) the explicit IPv4 loopback, not the wildcard address + "localhost": a wildcard bind
+		// can be handed an ephemeral port that another process already holds on 127.0.0.1 (SO_REUSEADDR), and that
+		// more-specific listener then wins every "localhost" connection -- the client hangs on its response.
+		server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
 		port = server.getAddress().getPort();
 		// Without an explicit executor, exchanges run on HttpServer's single internal dispatch thread, which
-		// starves under -T1C reactor-level parallel test load and can fail with "server failed to respond".
+		// is a bottleneck under -T1C parallel load. (The "server failed to respond" flake once blamed on starvation was most likely
+		// the wildcard-bind port shadowing fixed by binding 127.0.0.1 above; the executor is kept as harmless.)
 		executor = Executors.newCachedThreadPool();
 		server.setExecutor(executor);
 		server.createContext("/echo", exchange -> {
@@ -88,15 +94,15 @@ class ResponseContent_As_Coverage_Test {
 	}
 
 	private static String url() {
-		return "http://localhost:" + port + "/echo";
+		return "http://127.0.0.1:" + port + "/echo";
 	}
 
 	private static String nullJsonUrl() {
-		return "http://localhost:" + port + "/nullJson";
+		return "http://127.0.0.1:" + port + "/nullJson";
 	}
 
 	private static String noContentTypeUrl() {
-		return "http://localhost:" + port + "/noContentType";
+		return "http://127.0.0.1:" + port + "/noContentType";
 	}
 
 	/** Public no-arg constructor, so the "parser returned null but the type is constructible" fallback applies. */
diff --git a/juneau-rest/juneau-rest-client-classic/src/test/java/org/apache/juneau/rest/client/classic/ResponseContent_Coverage_Test.java b/juneau-rest/juneau-rest-client-classic/src/test/java/org/apache/juneau/rest/client/classic/ResponseContent_Coverage_Test.java
index 0ed25d6a80..329c5509eb 100644
--- a/juneau-rest/juneau-rest-client-classic/src/test/java/org/apache/juneau/rest/client/classic/ResponseContent_Coverage_Test.java
+++ b/juneau-rest/juneau-rest-client-classic/src/test/java/org/apache/juneau/rest/client/classic/ResponseContent_Coverage_Test.java
@@ -39,6 +39,8 @@ import com.sun.net.httpserver.*;
 @SuppressWarnings({
 	"resource" // Closeable resources in tests are intentionally unassigned; closing is handled by test infrastructure.
 })
+// SEPARATE_THREAD: a socket read blocked on a non-responding peer ignores interrupts, so SAME_THREAD would still hang.
+@Timeout(value = 60, threadMode = Timeout.ThreadMode.SEPARATE_THREAD)
 class ResponseContent_Coverage_Test {
 
 	private static HttpServer server;
@@ -47,10 +49,14 @@ class ResponseContent_Coverage_Test {
 
 	@BeforeAll
 	static void startServer() throws IOException {
-		server = HttpServer.create(new InetSocketAddress(0), 0);
+		// Bind to (and connect via) the explicit IPv4 loopback, not the wildcard address + "localhost": a wildcard bind
+		// can be handed an ephemeral port that another process already holds on 127.0.0.1 (SO_REUSEADDR), and that
+		// more-specific listener then wins every "localhost" connection -- the client hangs on its response.
+		server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
 		port = server.getAddress().getPort();
 		// Without an explicit executor, exchanges run on HttpServer's single internal dispatch thread, which
-		// starves under -T1C reactor-level parallel test load and can fail with "server failed to respond".
+		// is a bottleneck under -T1C parallel load. (The "server failed to respond" flake once blamed on starvation was most likely
+		// the wildcard-bind port shadowing fixed by binding 127.0.0.1 above; the executor is kept as harmless.)
 		executor = Executors.newCachedThreadPool();
 		server.setExecutor(executor);
 		server.createContext("/echo", exchange -> {
@@ -87,11 +93,11 @@ class ResponseContent_Coverage_Test {
 	}
 
 	private static String url() {
-		return "http://localhost:" + port + "/echo";
+		return "http://127.0.0.1:" + port + "/echo";
 	}
 
 	private static String truncatedUrl() {
-		return "http://localhost:" + port + "/truncated";
+		return "http://127.0.0.1:" + port + "/truncated";
 	}
 
 	@Test void a01_asBytes_basicHttpEntityFastPath() throws Exception {
diff --git a/juneau-rest/juneau-rest-client-classic/src/test/java/org/apache/juneau/rest/client/classic/ResponseContent_Cursor_Test.java b/juneau-rest/juneau-rest-client-classic/src/test/java/org/apache/juneau/rest/client/classic/ResponseContent_Cursor_Test.java
index 6d65cdfad1..cb66e4dfb6 100644
--- a/juneau-rest/juneau-rest-client-classic/src/test/java/org/apache/juneau/rest/client/classic/ResponseContent_Cursor_Test.java
+++ b/juneau-rest/juneau-rest-client-classic/src/test/java/org/apache/juneau/rest/client/classic/ResponseContent_Cursor_Test.java
@@ -41,6 +41,8 @@ import com.sun.net.httpserver.*;
 @SuppressWarnings({
 	"resource" // jsonContent() returns a ResponseContent while the underlying client (and its still-unconsumed response) must stay open for the caller to read the returned cursor; Eclipse JDT's @Owning warning is by design.
 })
+// SEPARATE_THREAD: a socket read blocked on a non-responding peer ignores interrupts, so SAME_THREAD would still hang.
+@Timeout(value = 60, threadMode = Timeout.ThreadMode.SEPARATE_THREAD)
 class ResponseContent_Cursor_Test {
 
 	public static class Bean {
@@ -57,10 +59,14 @@ class ResponseContent_Cursor_Test {
 
 	@BeforeAll
 	static void startServer() throws IOException {
-		server = HttpServer.create(new InetSocketAddress(0), 0);
+		// Bind to (and connect via) the explicit IPv4 loopback, not the wildcard address + "localhost": a wildcard bind
+		// can be handed an ephemeral port that another process already holds on 127.0.0.1 (SO_REUSEADDR), and that
+		// more-specific listener then wins every "localhost" connection -- the client hangs on its response.
+		server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
 		port = server.getAddress().getPort();
 		// Without an explicit executor, exchanges run on HttpServer's single internal dispatch thread, which
-		// starves under -T1C reactor-level parallel test load and can fail with "server failed to respond".
+		// is a bottleneck under -T1C parallel load. (The "server failed to respond" flake once blamed on starvation was most likely
+		// the wildcard-bind port shadowing fixed by binding 127.0.0.1 above; the executor is kept as harmless.)
 		executor = Executors.newCachedThreadPool();
 		server.setExecutor(executor);
 		server.createContext("/echo", exchange -> {
@@ -87,7 +93,7 @@ class ResponseContent_Cursor_Test {
 	}
 
 	private static String url() {
-		return "http://localhost:" + port + "/echo";
+		return "http://127.0.0.1:" + port + "/echo";
 	}
 
 	private static ResponseContent jsonContent(String json) throws Exception {
diff --git a/juneau-rest/juneau-rest-client-classic/src/test/java/org/apache/juneau/rest/client/classic/ResponseHeader_Test.java b/juneau-rest/juneau-rest-client-classic/src/test/java/org/apache/juneau/rest/client/classic/ResponseHeader_Test.java
index c53c59a80a..33a37601ae 100644
--- a/juneau-rest/juneau-rest-client-classic/src/test/java/org/apache/juneau/rest/client/classic/ResponseHeader_Test.java
+++ b/juneau-rest/juneau-rest-client-classic/src/test/java/org/apache/juneau/rest/client/classic/ResponseHeader_Test.java
@@ -30,6 +30,8 @@ import com.sun.net.httpserver.*;
  * Exercises the {@link ResponseHeader} convenience conversion methods that aren't already reached by
  * other end-to-end client tests.
  */
+// SEPARATE_THREAD: a socket read blocked on a non-responding peer ignores interrupts, so SAME_THREAD would still hang.
+@Timeout(value = 60, threadMode = Timeout.ThreadMode.SEPARATE_THREAD)
 class ResponseHeader_Test {
 
 	private static HttpServer server;
@@ -38,10 +40,14 @@ class ResponseHeader_Test {
 
 	@BeforeAll
 	static void startServer() throws IOException {
-		server = HttpServer.create(new InetSocketAddress(0), 0);
+		// Bind to (and connect via) the explicit IPv4 loopback, not the wildcard address + "localhost": a wildcard bind
+		// can be handed an ephemeral port that another process already holds on 127.0.0.1 (SO_REUSEADDR), and that
+		// more-specific listener then wins every "localhost" connection -- the client hangs on its response.
+		server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
 		port = server.getAddress().getPort();
 		// Without an explicit executor, exchanges run on HttpServer's single internal dispatch thread, which
-		// starves under -T1C reactor-level parallel test load and can fail with "server failed to respond".
+		// is a bottleneck under -T1C parallel load. (The "server failed to respond" flake once blamed on starvation was most likely
+		// the wildcard-bind port shadowing fixed by binding 127.0.0.1 above; the executor is kept as harmless.)
 		executor = Executors.newCachedThreadPool();
 		server.setExecutor(executor);
 		server.createContext("/headers", exchange -> {
@@ -69,7 +75,7 @@ class ResponseHeader_Test {
 		"resource" // Client/response instances are short-lived test fixtures.
 	})
 	private static RestResponse response() throws Exception {
-		var client = RestClient.create().rootUrl("http://localhost:" + port).build();
+		var client = RestClient.create().rootUrl("http://127.0.0.1:" + port).build();
 		return client.get("/headers").run();
 	}
 
diff --git a/juneau-rest/juneau-rest-client-classic/src/test/java/org/apache/juneau/rest/client/classic/ResponseStatusLine_Test.java b/juneau-rest/juneau-rest-client-classic/src/test/java/org/apache/juneau/rest/client/classic/ResponseStatusLine_Test.java
index 1bb496d0b7..8eb823b075 100644
--- a/juneau-rest/juneau-rest-client-classic/src/test/java/org/apache/juneau/rest/client/classic/ResponseStatusLine_Test.java
+++ b/juneau-rest/juneau-rest-client-classic/src/test/java/org/apache/juneau/rest/client/classic/ResponseStatusLine_Test.java
@@ -33,6 +33,8 @@ import com.sun.net.httpserver.*;
 @SuppressWarnings({
 	"resource" // response() returns a RestResponse whose ownership is transferred to the caller, who is responsible for closing it; the underlying client is a short-lived test fixture and Eclipse JDT's @Owning warning is by design.
 })
+// SEPARATE_THREAD: a socket read blocked on a non-responding peer ignores interrupts, so SAME_THREAD would still hang.
+@Timeout(value = 60, threadMode = Timeout.ThreadMode.SEPARATE_THREAD)
 class ResponseStatusLine_Test {
 
 	private static HttpServer server;
@@ -41,10 +43,14 @@ class ResponseStatusLine_Test {
 
 	@BeforeAll
 	static void startServer() throws IOException {
-		server = HttpServer.create(new InetSocketAddress(0), 0);
+		// Bind to (and connect via) the explicit IPv4 loopback, not the wildcard address + "localhost": a wildcard bind
+		// can be handed an ephemeral port that another process already holds on 127.0.0.1 (SO_REUSEADDR), and that
+		// more-specific listener then wins every "localhost" connection -- the client hangs on its response.
+		server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
 		port = server.getAddress().getPort();
 		// Without an explicit executor, exchanges run on HttpServer's single internal dispatch thread, which
-		// starves under -T1C reactor-level parallel test load and can fail with "server failed to respond".
+		// is a bottleneck under -T1C parallel load. (The "server failed to respond" flake once blamed on starvation was most likely
+		// the wildcard-bind port shadowing fixed by binding 127.0.0.1 above; the executor is kept as harmless.)
 		executor = Executors.newCachedThreadPool();
 		server.setExecutor(executor);
 		server.createContext("/ok", exchange -> {
@@ -63,7 +69,7 @@ class ResponseStatusLine_Test {
 	}
 
 	private static RestResponse response() throws Exception {
-		var client = RestClient.create().rootUrl("http://localhost:" + port).build();
+		var client = RestClient.create().rootUrl("http://127.0.0.1:" + port).build();
 		return client.get("/ok").run();
 	}
 
diff --git a/juneau-rest/juneau-rest-client-classic/src/test/java/org/apache/juneau/rest/client/classic/RestClient_Builder_Coverage_Test.java b/juneau-rest/juneau-rest-client-classic/src/test/java/org/apache/juneau/rest/client/classic/RestClient_Builder_Coverage_Test.java
index 90eae155c9..8e5df057f0 100644
--- a/juneau-rest/juneau-rest-client-classic/src/test/java/org/apache/juneau/rest/client/classic/RestClient_Builder_Coverage_Test.java
+++ b/juneau-rest/juneau-rest-client-classic/src/test/java/org/apache/juneau/rest/client/classic/RestClient_Builder_Coverage_Test.java
@@ -45,6 +45,8 @@ import com.sun.net.httpserver.*;
 @SuppressWarnings({
 	"resource" // Closeable resources in tests are intentionally unassigned; closing is handled by test infrastructure.
 })
+// SEPARATE_THREAD: a socket read blocked on a non-responding peer ignores interrupts, so SAME_THREAD would still hang.
+@Timeout(value = 60, threadMode = Timeout.ThreadMode.SEPARATE_THREAD)
 class RestClient_Builder_Coverage_Test {
 
 	private static HttpServer server;
@@ -55,10 +57,14 @@ class RestClient_Builder_Coverage_Test {
 
 	@BeforeAll
 	static void startServer() throws IOException {
-		server = HttpServer.create(new InetSocketAddress(0), 0);
+		// Bind to (and connect via) the explicit IPv4 loopback, not the wildcard address + "localhost": a wildcard bind
+		// can be handed an ephemeral port that another process already holds on 127.0.0.1 (SO_REUSEADDR), and that
+		// more-specific listener then wins every "localhost" connection -- the client hangs on its response.
+		server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
 		port = server.getAddress().getPort();
 		// Without an explicit executor, exchanges run on HttpServer's single internal dispatch thread, which
-		// starves under -T1C reactor-level parallel test load and can fail with "server failed to respond".
+		// is a bottleneck under -T1C parallel load. (The "server failed to respond" flake once blamed on starvation was most likely
+		// the wildcard-bind port shadowing fixed by binding 127.0.0.1 above; the executor is kept as harmless.)
 		executor = Executors.newCachedThreadPool();
 		server.setExecutor(executor);
 		server.createContext("/echo", exchange -> {
@@ -80,7 +86,7 @@ class RestClient_Builder_Coverage_Test {
 	}
 
 	private static String url() {
-		return "http://localhost:" + port + "/echo";
+		return "http://127.0.0.1:" + port + "/echo";
 	}
 
 	@Test void a01_addInterceptorFirst() throws Exception {
diff --git a/juneau-rest/juneau-rest-client-classic/src/test/java/org/apache/juneau/rest/client/classic/RestClient_Coverage2_Test.java b/juneau-rest/juneau-rest-client-classic/src/test/java/org/apache/juneau/rest/client/classic/RestClient_Coverage2_Test.java
index 011280f5c9..c58db4ccb6 100644
--- a/juneau-rest/juneau-rest-client-classic/src/test/java/org/apache/juneau/rest/client/classic/RestClient_Coverage2_Test.java
+++ b/juneau-rest/juneau-rest-client-classic/src/test/java/org/apache/juneau/rest/client/classic/RestClient_Coverage2_Test.java
@@ -48,6 +48,8 @@ import com.sun.net.httpserver.*;
 	"removal", // Tests invoke RestClient.finalize() directly to cover its leak-detection branches
 	"resource" // Several tests here intentionally leave a RestClient unclosed (to exercise close()/closeQuietly()/finalize() behavior directly) rather than using try-with-resources; Eclipse JDT's @Owning warning is by design.
 })
+// SEPARATE_THREAD: a socket read blocked on a non-responding peer ignores interrupts, so SAME_THREAD would still hang.
+@Timeout(value = 60, threadMode = Timeout.ThreadMode.SEPARATE_THREAD)
 class RestClient_Coverage2_Test {
 
 	private static HttpServer server;
@@ -56,10 +58,14 @@ class RestClient_Coverage2_Test {
 
 	@BeforeAll
 	static void startServer() throws IOException {
-		server = HttpServer.create(new InetSocketAddress(0), 0);
+		// Bind to (and connect via) the explicit IPv4 loopback, not the wildcard address + "localhost": a wildcard bind
+		// can be handed an ephemeral port that another process already holds on 127.0.0.1 (SO_REUSEADDR), and that
+		// more-specific listener then wins every "localhost" connection -- the client hangs on its response.
+		server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
 		port = server.getAddress().getPort();
 		// Without an explicit executor, exchanges run on HttpServer's single internal dispatch thread, which
-		// starves under -T1C reactor-level parallel test load and can fail with "server failed to respond".
+		// is a bottleneck under -T1C parallel load. (The "server failed to respond" flake once blamed on starvation was most likely
+		// the wildcard-bind port shadowing fixed by binding 127.0.0.1 above; the executor is kept as harmless.)
 		executor = Executors.newCachedThreadPool();
 		server.setExecutor(executor);
 		server.createContext("/echo", exchange -> {
@@ -79,7 +85,7 @@ class RestClient_Coverage2_Test {
 	}
 
 	private static String url() {
-		return "http://localhost:" + port;
+		return "http://127.0.0.1:" + port;
 	}
 
 	@Test void a01_skipEmptyXxxData_noArg_setsTrue() throws Exception {
diff --git a/juneau-rest/juneau-rest-client-classic/src/test/java/org/apache/juneau/rest/client/classic/RestClient_FormPost_Coverage_Test.java b/juneau-rest/juneau-rest-client-classic/src/test/java/org/apache/juneau/rest/client/classic/RestClient_FormPost_Coverage_Test.java
index 710f78c8a9..8f155061ce 100644
--- a/juneau-rest/juneau-rest-client-classic/src/test/java/org/apache/juneau/rest/client/classic/RestClient_FormPost_Coverage_Test.java
+++ b/juneau-rest/juneau-rest-client-classic/src/test/java/org/apache/juneau/rest/client/classic/RestClient_FormPost_Coverage_Test.java
@@ -43,6 +43,8 @@ import com.sun.net.httpserver.*;
 @SuppressWarnings({
 	"resource" // Closeable resources in tests (e.g. the StreamResource passed as a formPost() body) are intentionally unassigned; closing is handled by test infrastructure.
 })
+// SEPARATE_THREAD: a socket read blocked on a non-responding peer ignores interrupts, so SAME_THREAD would still hang.
+@Timeout(value = 60, threadMode = Timeout.ThreadMode.SEPARATE_THREAD)
 class RestClient_FormPost_Coverage_Test {
 
 	private static HttpServer server;
@@ -53,10 +55,14 @@ class RestClient_FormPost_Coverage_Test {
 
 	@BeforeAll
 	static void startServer() throws IOException {
-		server = HttpServer.create(new InetSocketAddress(0), 0);
+		// Bind to (and connect via) the explicit IPv4 loopback, not the wildcard address + "localhost": a wildcard bind
+		// can be handed an ephemeral port that another process already holds on 127.0.0.1 (SO_REUSEADDR), and that
+		// more-specific listener then wins every "localhost" connection -- the client hangs on its response.
+		server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
 		port = server.getAddress().getPort();
 		// Without an explicit executor, exchanges run on HttpServer's single internal dispatch thread, which
-		// starves under -T1C reactor-level parallel test load and can fail with "server failed to respond".
+		// is a bottleneck under -T1C parallel load. (The "server failed to respond" flake once blamed on starvation was most likely
+		// the wildcard-bind port shadowing fixed by binding 127.0.0.1 above; the executor is kept as harmless.)
 		executor = Executors.newCachedThreadPool();
 		server.setExecutor(executor);
 		server.createContext("/echo", exchange -> {
@@ -77,7 +83,7 @@ class RestClient_FormPost_Coverage_Test {
 	}
 
 	private static String url() {
-		return "http://localhost:" + port + "/echo";
+		return "http://127.0.0.1:" + port + "/echo";
 	}
 
 	@Test void a01_nameValuePair_singleton() throws Exception {
diff --git a/juneau-rest/juneau-rest-client-classic/src/test/java/org/apache/juneau/rest/client/classic/RestClient_RedirectCredentials_Test.java b/juneau-rest/juneau-rest-client-classic/src/test/java/org/apache/juneau/rest/client/classic/RestClient_RedirectCredentials_Test.java
index fb4cc6691c..4249c32282 100644
--- a/juneau-rest/juneau-rest-client-classic/src/test/java/org/apache/juneau/rest/client/classic/RestClient_RedirectCredentials_Test.java
+++ b/juneau-rest/juneau-rest-client-classic/src/test/java/org/apache/juneau/rest/client/classic/RestClient_RedirectCredentials_Test.java
@@ -35,6 +35,8 @@ import com.sun.net.httpserver.*;
 @SuppressWarnings({
 	"resource" // Client instances are short-lived test fixtures.
 })
+// SEPARATE_THREAD: a socket read blocked on a non-responding peer ignores interrupts, so SAME_THREAD would still hang.
+@Timeout(value = 60, threadMode = Timeout.ThreadMode.SEPARATE_THREAD)
 class RestClient_RedirectCredentials_Test {
 
 	private static HttpServer serverA;
@@ -46,22 +48,25 @@ class RestClient_RedirectCredentials_Test {
 	@BeforeAll
 	static void startServers() throws IOException {
 		// Without an explicit executor, exchanges run on each HttpServer's single internal dispatch thread,
-		// which starves under -T1C reactor-level parallel test load and can fail with "server failed to
-		// respond". One shared pool is enough since both servers only ever field short-lived test requests.
+		// which is a bottleneck under -T1C parallel load. (The "server failed to respond" flake once blamed on starvation was most
+		// likely the wildcard-bind port shadowing fixed by binding 127.0.0.1 above; the executor is kept as harmless.) One shared pool is enough since both servers only ever field short-lived test requests.
 		executor = Executors.newCachedThreadPool();
 
-		serverB = HttpServer.create(new InetSocketAddress(0), 0);
+		// Bind to (and connect via) the explicit IPv4 loopback, not the wildcard address + "localhost": a wildcard bind
+		// can be handed an ephemeral port that another process already holds on 127.0.0.1 (SO_REUSEADDR), and that
+		// more-specific listener then wins every "localhost" connection -- the client hangs on its response.
+		serverB = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
 		portB = serverB.getAddress().getPort();
 		serverB.setExecutor(executor);
 		serverB.createContext("/echo-creds", RestClient_RedirectCredentials_Test::echoCreds);
 		serverB.start();
 
-		serverA = HttpServer.create(new InetSocketAddress(0), 0);
+		serverA = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
 		portA = serverA.getAddress().getPort();
 		serverA.setExecutor(executor);
 		serverA.createContext("/echo-creds", RestClient_RedirectCredentials_Test::echoCreds);
-		serverA.createContext("/redirect-cross", exchange -> redirect(exchange, "http://localhost:" + portB + "/echo-creds"));
-		serverA.createContext("/redirect-same", exchange -> redirect(exchange, "http://localhost:" + portA + "/echo-creds"));
+		serverA.createContext("/redirect-cross", exchange -> redirect(exchange, "http://127.0.0.1:" + portB + "/echo-creds"));
+		serverA.createContext("/redirect-same", exchange -> redirect(exchange, "http://127.0.0.1:" + portA + "/echo-creds"));
 		serverA.start();
 	}
 
@@ -94,7 +99,7 @@ class RestClient_RedirectCredentials_Test {
 
 	@Test
 	void a01_crossOrigin_stripsCredentials() throws Exception {
-		try (var client = RestClient.create().rootUrl("http://localhost:" + portA).build()) {
+		try (var client = RestClient.create().rootUrl("http://127.0.0.1:" + portA).build()) {
 			try (var response = client.get("/redirect-cross")
 					.header("Authorization", "Bearer secret-token")
 					.header("Cookie", "session=abc123")
@@ -107,7 +112,7 @@ class RestClient_RedirectCredentials_Test {
 
 	@Test
 	void a02_sameOrigin_forwardsCredentials() throws Exception {
-		try (var client = RestClient.create().rootUrl("http://localhost:" + portA).build()) {
+		try (var client = RestClient.create().rootUrl("http://127.0.0.1:" + portA).build()) {
 			try (var response = client.get("/redirect-same")
 					.header("Authorization", "Bearer secret-token")
 					.run()) {
diff --git a/juneau-rest/juneau-rest-client-classic/src/test/java/org/apache/juneau/rest/client/classic/RestRequest_ArgExpansion_Coverage_Test.java b/juneau-rest/juneau-rest-client-classic/src/test/java/org/apache/juneau/rest/client/classic/RestRequest_ArgExpansion_Coverage_Test.java
index d43f8b0e76..efe11bd1a4 100644
--- a/juneau-rest/juneau-rest-client-classic/src/test/java/org/apache/juneau/rest/client/classic/RestRequest_ArgExpansion_Coverage_Test.java
+++ b/juneau-rest/juneau-rest-client-classic/src/test/java/org/apache/juneau/rest/client/classic/RestRequest_ArgExpansion_Coverage_Test.java
@@ -43,6 +43,8 @@ import com.sun.net.httpserver.*;
 @SuppressWarnings({
 	"resource" // req() returns a RestRequest whose ownership is transferred to the caller, who is responsible for closing it; some assigned-but-discarded 'r' locals on exception paths are the same already-tracked instance; Eclipse JDT's @Owning warning is by design.
 })
+// SEPARATE_THREAD: a socket read blocked on a non-responding peer ignores interrupts, so SAME_THREAD would still hang.
+@Timeout(value = 60, threadMode = Timeout.ThreadMode.SEPARATE_THREAD)
 class RestRequest_ArgExpansion_Coverage_Test {
 
 	private static HttpServer server;
@@ -51,10 +53,14 @@ class RestRequest_ArgExpansion_Coverage_Test {
 
 	@BeforeAll
 	static void startServer() throws IOException {
-		server = HttpServer.create(new InetSocketAddress(0), 0);
+		// Bind to (and connect via) the explicit IPv4 loopback, not the wildcard address + "localhost": a wildcard bind
+		// can be handed an ephemeral port that another process already holds on 127.0.0.1 (SO_REUSEADDR), and that
+		// more-specific listener then wins every "localhost" connection -- the client hangs on its response.
+		server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
 		port = server.getAddress().getPort();
 		// Without an explicit executor, exchanges run on HttpServer's single internal dispatch thread, which
-		// starves under -T1C reactor-level parallel test load and can fail with "server failed to respond".
+		// is a bottleneck under -T1C parallel load. (The "server failed to respond" flake once blamed on starvation was most likely
+		// the wildcard-bind port shadowing fixed by binding 127.0.0.1 above; the executor is kept as harmless.)
 		executor = Executors.newCachedThreadPool();
 		server.setExecutor(executor);
 		server.createContext("/echo", exchange -> {
@@ -74,7 +80,7 @@ class RestRequest_ArgExpansion_Coverage_Test {
 	}
 
 	private static String url() {
-		return "http://localhost:" + port + "/echo";
+		return "http://127.0.0.1:" + port + "/echo";
 	}
 
 	private static RestRequest req() {
diff --git a/juneau-rest/juneau-rest-client-classic/src/test/java/org/apache/juneau/rest/client/classic/RestRequest_Rethrow_Coverage_Test.java b/juneau-rest/juneau-rest-client-classic/src/test/java/org/apache/juneau/rest/client/classic/RestRequest_Rethrow_Coverage_Test.java
index dd0726c5bc..7637bad009 100644
--- a/juneau-rest/juneau-rest-client-classic/src/test/java/org/apache/juneau/rest/client/classic/RestRequest_Rethrow_Coverage_Test.java
+++ b/juneau-rest/juneau-rest-client-classic/src/test/java/org/apache/juneau/rest/client/classic/RestRequest_Rethrow_Coverage_Test.java
@@ -35,6 +35,8 @@ import com.sun.net.httpserver.*;
 @SuppressWarnings({
 	"resource" // client() returns a RestClient whose ownership is transferred to the caller, who is responsible for closing it; Eclipse JDT's @Owning warning is by design.
 })
+// SEPARATE_THREAD: a socket read blocked on a non-responding peer ignores interrupts, so SAME_THREAD would still hang.
+@Timeout(value = 60, threadMode = Timeout.ThreadMode.SEPARATE_THREAD)
 class RestRequest_Rethrow_Coverage_Test {
 
 	// Constructors (and classes) are public: RestRequest#run() locates rethrow candidates via
@@ -82,10 +84,14 @@ class RestRequest_Rethrow_Coverage_Test {
 
 	@BeforeAll
 	static void startServer() throws IOException {
-		server = HttpServer.create(new InetSocketAddress(0), 0);
+		// Bind to (and connect via) the explicit IPv4 loopback, not the wildcard address + "localhost": a wildcard bind
+		// can be handed an ephemeral port that another process already holds on 127.0.0.1 (SO_REUSEADDR), and that
+		// more-specific listener then wins every "localhost" connection -- the client hangs on its response.
+		server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
 		port = server.getAddress().getPort();
 		// Without an explicit executor, exchanges run on HttpServer's single internal dispatch thread, which
-		// starves under -T1C reactor-level parallel test load and can fail with "server failed to respond".
+		// is a bottleneck under -T1C parallel load. (The "server failed to respond" flake once blamed on starvation was most likely
+		// the wildcard-bind port shadowing fixed by binding 127.0.0.1 above; the executor is kept as harmless.)
 		executor = Executors.newCachedThreadPool();
 		server.setExecutor(executor);
 		server.createContext("/thrown", exchange -> {
@@ -121,7 +127,7 @@ class RestRequest_Rethrow_Coverage_Test {
 	}
 
 	private static String url(String path) {
-		return "http://localhost:" + port + path;
+		return "http://127.0.0.1:" + port + path;
 	}
 
 	private static RestClient client() {
diff --git a/juneau-rest/juneau-rest-client-classic/src/test/java/org/apache/juneau/rest/client/classic/RestRequest_SessionOptions_Coverage_Test.java b/juneau-rest/juneau-rest-client-classic/src/test/java/org/apache/juneau/rest/client/classic/RestRequest_SessionOptions_Coverage_Test.java
index 2a3b4ae915..92f85f07b4 100644
--- a/juneau-rest/juneau-rest-client-classic/src/test/java/org/apache/juneau/rest/client/classic/RestRequest_SessionOptions_Coverage_Test.java
+++ b/juneau-rest/juneau-rest-client-classic/src/test/java/org/apache/juneau/rest/client/classic/RestRequest_SessionOptions_Coverage_Test.java
@@ -37,6 +37,8 @@ import com.sun.net.httpserver.*;
 @SuppressWarnings({
 	"resource" // req() returns a RestRequest whose ownership is transferred to the caller, who is responsible for closing it; Eclipse JDT's @Owning warning is by design.
 })
+// SEPARATE_THREAD: a socket read blocked on a non-responding peer ignores interrupts, so SAME_THREAD would still hang.
+@Timeout(value = 60, threadMode = Timeout.ThreadMode.SEPARATE_THREAD)
 class RestRequest_SessionOptions_Coverage_Test {
 
 	private static HttpServer server;
@@ -47,10 +49,14 @@ class RestRequest_SessionOptions_Coverage_Test {
 
 	@BeforeAll
 	static void startServer() throws IOException {
-		server = HttpServer.create(new InetSocketAddress(0), 0);
+		// Bind to (and connect via) the explicit IPv4 loopback, not the wildcard address + "localhost": a wildcard bind
+		// can be handed an ephemeral port that another process already holds on 127.0.0.1 (SO_REUSEADDR), and that
+		// more-specific listener then wins every "localhost" connection -- the client hangs on its response.
+		server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
 		port = server.getAddress().getPort();
 		// Without an explicit executor, exchanges run on HttpServer's single internal dispatch thread, which
-		// starves under -T1C reactor-level parallel test load and can fail with "server failed to respond".
+		// is a bottleneck under -T1C parallel load. (The "server failed to respond" flake once blamed on starvation was most likely
+		// the wildcard-bind port shadowing fixed by binding 127.0.0.1 above; the executor is kept as harmless.)
 		executor = Executors.newCachedThreadPool();
 		server.setExecutor(executor);
 		server.createContext("/x", exchange -> {
@@ -73,7 +79,7 @@ class RestRequest_SessionOptions_Coverage_Test {
 	}
 
 	private static RestRequest req() throws RestCallException {
-		return RestClient.create().build().get("http://localhost:" + port + "/x");
+		return RestClient.create().build().get("http://127.0.0.1:" + port + "/x");
 	}
 
 	@Test void a01_serializerSessionOptionsHeader_emptyMap_returnsThisWithoutSettingHeader() throws Exception {
diff --git a/juneau-rest/juneau-rest-client-classic/src/test/java/org/apache/juneau/rest/client/classic/RestRequest_StreamBodyEntity_Coverage_Test.java b/juneau-rest/juneau-rest-client-classic/src/test/java/org/apache/juneau/rest/client/classic/RestRequest_StreamBodyEntity_Coverage_Test.java
index c9df3fe2b9..1f08bb2638 100644
--- a/juneau-rest/juneau-rest-client-classic/src/test/java/org/apache/juneau/rest/client/classic/RestRequest_StreamBodyEntity_Coverage_Test.java
+++ b/juneau-rest/juneau-rest-client-classic/src/test/java/org/apache/juneau/rest/client/classic/RestRequest_StreamBodyEntity_Coverage_Test.java
@@ -42,6 +42,8 @@ import com.sun.net.httpserver.*;
 @SuppressWarnings({
 	"resource" // Closeable resources in tests are intentionally unassigned; closing is handled by test infrastructure.
 })
+// SEPARATE_THREAD: a socket read blocked on a non-responding peer ignores interrupts, so SAME_THREAD would still hang.
+@Timeout(value = 60, threadMode = Timeout.ThreadMode.SEPARATE_THREAD)
 class RestRequest_StreamBodyEntity_Coverage_Test {
 
 	/** A bare {@link Serializer} implementing neither {@link RecordWritable} nor {@link TokenWritable}. */
@@ -64,10 +66,14 @@ class RestRequest_StreamBodyEntity_Coverage_Test {
 
 	@BeforeAll
 	static void startServer() throws IOException {
-		server = HttpServer.create(new InetSocketAddress(0), 0);
+		// Bind to (and connect via) the explicit IPv4 loopback, not the wildcard address + "localhost": a wildcard bind
+		// can be handed an ephemeral port that another process already holds on 127.0.0.1 (SO_REUSEADDR), and that
+		// more-specific listener then wins every "localhost" connection -- the client hangs on its response.
+		server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
 		port = server.getAddress().getPort();
 		// Without an explicit executor, exchanges run on HttpServer's single internal dispatch thread, which
-		// starves under -T1C reactor-level parallel test load and can fail with "server failed to respond".
+		// is a bottleneck under -T1C parallel load. (The "server failed to respond" flake once blamed on starvation was most likely
+		// the wildcard-bind port shadowing fixed by binding 127.0.0.1 above; the executor is kept as harmless.)
 		executor = Executors.newCachedThreadPool();
 		server.setExecutor(executor);
 		server.createContext("/echo", exchange -> {
@@ -87,7 +93,7 @@ class RestRequest_StreamBodyEntity_Coverage_Test {
 	}
 
 	private static String url() {
-		return "http://localhost:" + port + "/echo";
+		return "http://127.0.0.1:" + port + "/echo";
 	}
 
 	// ==========================================================================
diff --git a/juneau-rest/juneau-rest-client-classic/src/test/java/org/apache/juneau/rest/client/classic/assertion/FluentResponseBodyAssertion_Test.java b/juneau-rest/juneau-rest-client-classic/src/test/java/org/apache/juneau/rest/client/classic/assertion/FluentResponseBodyAssertion_Test.java
index 01fc42773c..e67c91c5bc 100644
--- a/juneau-rest/juneau-rest-client-classic/src/test/java/org/apache/juneau/rest/client/classic/assertion/FluentResponseBodyAssertion_Test.java
+++ b/juneau-rest/juneau-rest-client-classic/src/test/java/org/apache/juneau/rest/client/classic/assertion/FluentResponseBodyAssertion_Test.java
@@ -43,6 +43,8 @@ import com.sun.net.httpserver.*;
 @SuppressWarnings({
 	"resource" // okResponse()/brokenResponse() return a RestResponse whose ownership is transferred to the caller, who is responsible for closing it; the underlying client is a short-lived test fixture and Eclipse JDT's @Owning warning is by design.
 })
+// SEPARATE_THREAD: a socket read blocked on a non-responding peer ignores interrupts, so SAME_THREAD would still hang.
+@Timeout(value = 60, threadMode = Timeout.ThreadMode.SEPARATE_THREAD)
 class FluentResponseBodyAssertion_Test {
 
 	private static HttpServer server;
@@ -51,10 +53,14 @@ class FluentResponseBodyAssertion_Test {
 
 	@BeforeAll
 	static void startServer() throws IOException {
-		server = HttpServer.create(new InetSocketAddress(0), 0);
+		// Bind to (and connect via) the explicit IPv4 loopback, not the wildcard address + "localhost": a wildcard bind
+		// can be handed an ephemeral port that another process already holds on 127.0.0.1 (SO_REUSEADDR), and that
+		// more-specific listener then wins every "localhost" connection -- the client hangs on its response.
+		server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
 		port = server.getAddress().getPort();
 		// Without an explicit executor, exchanges run on HttpServer's single internal dispatch thread, which
-		// starves under -T1C reactor-level parallel test load and can fail with "server failed to respond".
+		// is a bottleneck under -T1C parallel load. (The "server failed to respond" flake once blamed on starvation was most likely
+		// the wildcard-bind port shadowing fixed by binding 127.0.0.1 above; the executor is kept as harmless.)
 		executor = Executors.newCachedThreadPool();
 		server.setExecutor(executor);
 		server.createContext("/ok", exchange -> {
@@ -88,12 +94,12 @@ class FluentResponseBodyAssertion_Test {
 	}
 
 	private static RestResponse okResponse() throws Exception {
-		var client = RestClient.create().rootUrl("http://localhost:" + port).build();
+		var client = RestClient.create().rootUrl("http://127.0.0.1:" + port).build();
 		return client.get("/ok").run();
 	}
 
 	private static RestResponse brokenResponse() throws Exception {
-		var client = RestClient.create().rootUrl("http://localhost:" + port).build();
+		var client = RestClient.create().rootUrl("http://127.0.0.1:" + port).build();
 		return client.get("/broken").run();
 	}
 
diff --git a/juneau-rest/juneau-rest-client-jetty/src/main/java/org/apache/juneau/rest/client/jetty/JettyHttpTransport.java b/juneau-rest/juneau-rest-client-jetty/src/main/java/org/apache/juneau/rest/client/jetty/JettyHttpTransport.java
index 9459da09a4..617e08a681 100644
--- a/juneau-rest/juneau-rest-client-jetty/src/main/java/org/apache/juneau/rest/client/jetty/JettyHttpTransport.java
+++ b/juneau-rest/juneau-rest-client-jetty/src/main/java/org/apache/juneau/rest/client/jetty/JettyHttpTransport.java
@@ -167,7 +167,10 @@ public final class JettyHttpTransport implements HttpTransport {
 		jettyRequest.send(listener);
 		Response jettyResponse;
 		try {
-			jettyResponse = listener.get(responseTimeoutMs > 0 ? responseTimeoutMs : Long.MAX_VALUE, TimeUnit.MILLISECONDS);
+			// A per-request timeout overrides the transport-level response header timeout.
+			var perRequest = request.getTimeout();
+			var waitMs = perRequest != null ? Math.max(1, perRequest.toMillis()) : (responseTimeoutMs > 0 ? responseTimeoutMs : Long.MAX_VALUE);
+			jettyResponse = listener.get(waitMs, TimeUnit.MILLISECONDS);
 		} catch (InterruptedException e) {
 			Thread.currentThread().interrupt();
 			abortQuietly(listener);
diff --git a/juneau-rest/juneau-rest-client-jetty/src/test/java/org/apache/juneau/rest/client/JettyHttpTransport_RedirectCredentials_Test.java b/juneau-rest/juneau-rest-client-jetty/src/test/java/org/apache/juneau/rest/client/JettyHttpTransport_RedirectCredentials_Test.java
index c6ec05e404..3fec75906c 100644
--- a/juneau-rest/juneau-rest-client-jetty/src/test/java/org/apache/juneau/rest/client/JettyHttpTransport_RedirectCredentials_Test.java
+++ b/juneau-rest/juneau-rest-client-jetty/src/test/java/org/apache/juneau/rest/client/JettyHttpTransport_RedirectCredentials_Test.java
@@ -40,6 +40,8 @@ import com.sun.net.httpserver.*;
 @SuppressWarnings({
 	"resource" // Transport/client instances are short-lived test fixtures.
 })
+// SEPARATE_THREAD: a socket read blocked on a non-responding peer ignores interrupts, so SAME_THREAD would still hang.
+@Timeout(value = 60, threadMode = Timeout.ThreadMode.SEPARATE_THREAD)
 class JettyHttpTransport_RedirectCredentials_Test {
 
 	private static HttpServer serverA;
@@ -49,16 +51,18 @@ class JettyHttpTransport_RedirectCredentials_Test {
 
 	@BeforeAll
 	static void startServers() throws IOException {
-		serverB = HttpServer.create(new InetSocketAddress(0), 0);
+		// Bind to (and connect via) the explicit IPv4 loopback, not the wildcard address + "localhost": a wildcard bind can be
+		// handed an ephemeral port another process already holds on 127.0.0.1 (SO_REUSEADDR), which then wins every "localhost" connection.
+		serverB = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
 		portB = serverB.getAddress().getPort();
 		serverB.createContext("/echo-creds", JettyHttpTransport_RedirectCredentials_Test::echoCreds);
 		serverB.start();
 
-		serverA = HttpServer.create(new InetSocketAddress(0), 0);
+		serverA = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
 		portA = serverA.getAddress().getPort();
 		serverA.createContext("/echo-creds", JettyHttpTransport_RedirectCredentials_Test::echoCreds);
-		serverA.createContext("/redirect-cross", exchange -> redirect(exchange, "http://localhost:" + portB + "/echo-creds"));
-		serverA.createContext("/redirect-same", exchange -> redirect(exchange, "http://localhost:" + portA + "/echo-creds"));
+		serverA.createContext("/redirect-cross", exchange -> redirect(exchange, "http://127.0.0.1:" + portB + "/echo-creds"));
+		serverA.createContext("/redirect-same", exchange -> redirect(exchange, "http://127.0.0.1:" + portA + "/echo-creds"));
 		serverA.start();
 	}
 
@@ -92,7 +96,7 @@ class JettyHttpTransport_RedirectCredentials_Test {
 	@Test
 	void a01_crossOrigin_stripsCredentials() throws Exception {
 		var transport = JettyHttpTransport.create();
-		try (var client = RestClient.builder().transport(transport).rootUrl("http://localhost:" + portA).build()) {
+		try (var client = RestClient.builder().transport(transport).rootUrl("http://127.0.0.1:" + portA).build()) {
 			try (var response = client.get("/redirect-cross")
 					.header("X-API-Key", "secret-key")
 					.run()) {
@@ -105,7 +109,7 @@ class JettyHttpTransport_RedirectCredentials_Test {
 	@Test
 	void a02_sameOrigin_forwardsCredentials() throws Exception {
 		var transport = JettyHttpTransport.create();
-		try (var client = RestClient.builder().transport(transport).rootUrl("http://localhost:" + portA).build()) {
+		try (var client = RestClient.builder().transport(transport).rootUrl("http://127.0.0.1:" + portA).build()) {
 			try (var response = client.get("/redirect-same")
 					.header("X-API-Key", "secret-key")
 					.run()) {
diff --git a/juneau-rest/juneau-rest-client-jetty/src/test/java/org/apache/juneau/rest/client/JettyHttpTransport_StaleConnectionRetry_Test.java b/juneau-rest/juneau-rest-client-jetty/src/test/java/org/apache/juneau/rest/client/JettyHttpTransport_StaleConnectionRetry_Test.java
index 81048c6d04..7cf4a89619 100644
--- a/juneau-rest/juneau-rest-client-jetty/src/test/java/org/apache/juneau/rest/client/JettyHttpTransport_StaleConnectionRetry_Test.java
+++ b/juneau-rest/juneau-rest-client-jetty/src/test/java/org/apache/juneau/rest/client/JettyHttpTransport_StaleConnectionRetry_Test.java
@@ -35,6 +35,9 @@ import org.junit.jupiter.api.*;
 @SuppressWarnings({
 	"resource" // Transport/client instances are short-lived test fixtures.
 })
+// SEPARATE_THREAD: a socket read blocked on a non-responding peer ignores interrupts, so SAME_THREAD would still hang.
+// Fixtures bind/connect via explicit 127.0.0.1: a wildcard bind can be handed a port another process holds on 127.0.0.1.
+@Timeout(value = 60, threadMode = Timeout.ThreadMode.SEPARATE_THREAD)
 class JettyHttpTransport_StaleConnectionRetry_Test {
 
 	// =================================================================================================================
@@ -85,14 +88,14 @@ class JettyHttpTransport_StaleConnectionRetry_Test {
 
 		A01_StaleServer(int staleConnections) throws IOException {
 			this.staleConnections = staleConnections;
-			this.serverSocket = new ServerSocket(0);
+			this.serverSocket = new ServerSocket(0, 50, InetAddress.getByName("127.0.0.1"));
 			var acceptThread = new Thread(this::acceptLoop, "jetty-stale-server");
 			acceptThread.setDaemon(true);
 			acceptThread.start();
 		}
 
 		String rootUrl() {
-			return "http://localhost:" + serverSocket.getLocalPort();
+			return "http://127.0.0.1:" + serverSocket.getLocalPort();
 		}
 
 		int connectionCount() {
diff --git a/juneau-rest/juneau-rest-client-jetty/src/test/java/org/apache/juneau/rest/client/JettyHttpTransport_Test.java b/juneau-rest/juneau-rest-client-jetty/src/test/java/org/apache/juneau/rest/client/JettyHttpTransport_Test.java
index ec2bd34885..5b0f2206e7 100644
--- a/juneau-rest/juneau-rest-client-jetty/src/test/java/org/apache/juneau/rest/client/JettyHttpTransport_Test.java
+++ b/juneau-rest/juneau-rest-client-jetty/src/test/java/org/apache/juneau/rest/client/JettyHttpTransport_Test.java
@@ -35,6 +35,8 @@ import com.sun.net.httpserver.*;
 @SuppressWarnings({
 	"resource" // Transport/response Closeables are exercised inline; lifecycle is managed by the test/framework, not a real leak.
 })
+// SEPARATE_THREAD: a socket read blocked on a non-responding peer ignores interrupts, so SAME_THREAD would still hang.
+@Timeout(value = 60, threadMode = Timeout.ThreadMode.SEPARATE_THREAD)
 class JettyHttpTransport_Test {
 
 	private static HttpServer server;
@@ -42,7 +44,9 @@ class JettyHttpTransport_Test {
 
 	@BeforeAll
 	static void startServer() throws IOException {
-		server = HttpServer.create(new InetSocketAddress(0), 0);
+		// Bind to (and connect via) the explicit IPv4 loopback, not the wildcard address + "localhost": a wildcard bind can be
+		// handed an ephemeral port another process already holds on 127.0.0.1 (SO_REUSEADDR), which then wins every "localhost" connection.
+		server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
 		port = server.getAddress().getPort();
 
 		server.createContext("/hello", exchange -> {
@@ -98,7 +102,7 @@ class JettyHttpTransport_Test {
 	}
 
 	private String rootUrl() {
-		return "http://localhost:" + port;
+		return "http://127.0.0.1:" + port;
 	}
 
 	// =================================================================================================================
diff --git a/juneau-rest/juneau-rest-client-jetty/src/test/java/org/apache/juneau/rest/client/JettyHttpTransport_Timeout_Test.java b/juneau-rest/juneau-rest-client-jetty/src/test/java/org/apache/juneau/rest/client/JettyHttpTransport_Timeout_Test.java
new file mode 100644
index 0000000000..744ea7765a
--- /dev/null
+++ b/juneau-rest/juneau-rest-client-jetty/src/test/java/org/apache/juneau/rest/client/JettyHttpTransport_Timeout_Test.java
@@ -0,0 +1,111 @@
+/*
+ * 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.juneau.rest.client;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.io.*;
+import java.net.*;
+import java.nio.charset.*;
+import java.time.*;
+
+import org.apache.juneau.rest.client.*;
+import org.apache.juneau.rest.client.jetty.*;
+import org.junit.jupiter.api.*;
+
+import com.sun.net.httpserver.*;
+
+/**
+ * Verifies that {@link TransportRequest#getTimeout()} is honored per request by {@code JettyHttpTransport} (WORK-J0596).
+ *
+ * <p>
+ * Uses an embedded server whose {@code /slow} endpoint delays 2s before responding.
+ */
+@SuppressWarnings({
+	"java:S2925", // The server handler sleeps to simulate a slow upstream.
+	"resource" // Transport/response instances are short-lived test fixtures.
+})
+// SEPARATE_THREAD: a socket read blocked on a non-responding peer ignores interrupts, so SAME_THREAD would still hang.
+@Timeout(value = 60, threadMode = Timeout.ThreadMode.SEPARATE_THREAD)
+class JettyHttpTransport_Timeout_Test {
+
+	private static HttpServer server;
+	private static int port;
+
+	@BeforeAll
+	static void startServer() throws IOException {
+		// Bind to (and connect via) the explicit IPv4 loopback, not the wildcard address + "localhost": a wildcard bind can be
+		// handed an ephemeral port another process already holds on 127.0.0.1 (SO_REUSEADDR), which then wins every "localhost" connection.
+		server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+		port = server.getAddress().getPort();
+		server.createContext("/slow", exchange -> {
+			try {
+				Thread.sleep(2000);
+				var body = "ok".getBytes(StandardCharsets.UTF_8);
+				exchange.sendResponseHeaders(200, body.length);
+				exchange.getResponseBody().write(body);
+			} catch (InterruptedException | IOException e) {
+				// Client gave up; nothing to do.
+			} finally {
+				exchange.close();
+			}
+		});
+		server.createContext("/fast", exchange -> {
+			var body = "ok".getBytes(StandardCharsets.UTF_8);
+			exchange.sendResponseHeaders(200, body.length);
+			exchange.getResponseBody().write(body);
+			exchange.close();
+		});
+		server.start();
+	}
+
+	@AfterAll
+	static void stopServer() {
+		server.stop(0);
+	}
+
+	private static TransportRequest request(String path, Duration timeout) {
+		return TransportRequest.builder().method("GET").uri("http://127.0.0.1:" + port + path).timeout(timeout).build();
+	}
+
+	@Test void a01_shortTimeout_timesOutQuickly() throws Exception {
+		try (var transport = JettyHttpTransport.create()) {
+			var start = System.nanoTime();
+			var e = assertThrows(TransportException.class, () -> transport.execute(request("/slow", Duration.ofMillis(200))));
+			assertTrue(Duration.ofNanos(System.nanoTime() - start).toMillis() < 1800, "should time out well before the 2s server delay");
+			assertNotNull(e.getCause());
+		}
+	}
+
+	@Test void a02_noTimeout_waitsForResponse() throws Exception {
+		try (var transport = JettyHttpTransport.create(); var response = transport.execute(request("/slow", null))) {
+			assertEquals(200, response.getStatusCode());
+		}
+	}
+
+	@Test void a03_longTimeout_succeeds() throws Exception {
+		try (var transport = JettyHttpTransport.create(); var response = transport.execute(request("/slow", Duration.ofSeconds(10)))) {
+			assertEquals(200, response.getStatusCode());
+		}
+	}
+
+	@Test void a04_shortTimeout_fastEndpoint_succeeds() throws Exception {
+		try (var transport = JettyHttpTransport.create(); var response = transport.execute(request("/fast", Duration.ofMillis(200)))) {
+			assertEquals(200, response.getStatusCode());
+		}
+	}
+}
diff --git a/juneau-rest/juneau-rest-client-jetty/src/test/java/org/apache/juneau/rest/client/JettyHttpTransport_TransportFailure_Test.java b/juneau-rest/juneau-rest-client-jetty/src/test/java/org/apache/juneau/rest/client/JettyHttpTransport_TransportFailure_Test.java
index 9c6bb3bea4..5b5eb5f831 100644
--- a/juneau-rest/juneau-rest-client-jetty/src/test/java/org/apache/juneau/rest/client/JettyHttpTransport_TransportFailure_Test.java
+++ b/juneau-rest/juneau-rest-client-jetty/src/test/java/org/apache/juneau/rest/client/JettyHttpTransport_TransportFailure_Test.java
@@ -38,6 +38,9 @@ import org.junit.jupiter.api.*;
 	"java:S2925", // The 200ms sleep lets the request get in flight and block on the never-responding server before the thread is interrupted.
 	"resource" // Transport/client instances are short-lived test fixtures.
 })
+// SEPARATE_THREAD: a socket read blocked on a non-responding peer ignores interrupts, so SAME_THREAD would still hang.
+// Fixtures bind/connect via explicit 127.0.0.1: a wildcard bind can be handed a port another process holds on 127.0.0.1.
+@Timeout(value = 60, threadMode = Timeout.ThreadMode.SEPARATE_THREAD)
 class JettyHttpTransport_TransportFailure_Test {
 
 	// -----------------------------------------------------------------------------------------------------------------
@@ -49,14 +52,14 @@ class JettyHttpTransport_TransportFailure_Test {
 		private final ServerSocket serverSocket;
 
 		NeverRespondingServer() throws IOException {
-			this.serverSocket = new ServerSocket(0);
+			this.serverSocket = new ServerSocket(0, 50, InetAddress.getByName("127.0.0.1"));
 			var acceptThread = new Thread(this::acceptLoop, "jetty-never-responding-server");
 			acceptThread.setDaemon(true);
 			acceptThread.start();
 		}
 
 		String rootUrl() {
-			return "http://localhost:" + serverSocket.getLocalPort();
+			return "http://127.0.0.1:" + serverSocket.getLocalPort();
 		}
 
 		private void acceptLoop() {
@@ -127,12 +130,12 @@ class JettyHttpTransport_TransportFailure_Test {
 	@Test
 	void c01_connectionRefused_throwsGenericTransportException() throws Exception {
 		int freePort;
-		try (var probe = new ServerSocket(0)) {
+		try (var probe = new ServerSocket(0, 50, InetAddress.getByName("127.0.0.1"))) {
 			freePort = probe.getLocalPort();
 		}
 		// Nothing is listening on freePort now that the probe socket above has been closed.
 		var transport = JettyHttpTransport.create();
-		var request = TransportRequest.builder().method("GET").uri(URI.create("http://localhost:" + freePort + "/x")).build();
+		var request = TransportRequest.builder().method("GET").uri(URI.create("http://127.0.0.1:" + freePort + "/x")).build();
 		var ex = assertThrows(TransportException.class, () -> transport.execute(request));
 		assertTrue(ex.getMessage().contains("HTTP transport error"), ex.getMessage());
 	}
@@ -143,7 +146,7 @@ class JettyHttpTransport_TransportFailure_Test {
 
 	@Test
 	void d01_bodyWriteFails_requestAborted() throws Exception {
-		var serverSocket = new ServerSocket(0);
+		var serverSocket = new ServerSocket(0, 50, InetAddress.getByName("127.0.0.1"));
 		// Signaled once the server has actually accepted the TCP connection, so the writer thread below
 		// cannot fail before HttpClient.send() has genuinely started the exchange (see failingBody, below).
 		var connectionAccepted = new CountDownLatch(1);
@@ -189,7 +192,7 @@ class JettyHttpTransport_TransportFailure_Test {
 			};
 			var request = TransportRequest.builder()
 				.method("POST")
-				.uri(URI.create("http://localhost:" + serverSocket.getLocalPort() + "/x"))
+				.uri(URI.create("http://127.0.0.1:" + serverSocket.getLocalPort() + "/x"))
 				.body(TransportBody.of(failingBody))
 				.build();
 			// The background writer thread hits the IOException catch and closes the content stream,
diff --git a/juneau-rest/juneau-rest-client-okhttp/src/main/java/org/apache/juneau/rest/client/okhttp/OkHttpTransport.java b/juneau-rest/juneau-rest-client-okhttp/src/main/java/org/apache/juneau/rest/client/okhttp/OkHttpTransport.java
index e90bab9f62..0ab6e754c2 100644
--- a/juneau-rest/juneau-rest-client-okhttp/src/main/java/org/apache/juneau/rest/client/okhttp/OkHttpTransport.java
+++ b/juneau-rest/juneau-rest-client-okhttp/src/main/java/org/apache/juneau/rest/client/okhttp/OkHttpTransport.java
@@ -19,6 +19,7 @@ package org.apache.juneau.rest.client.okhttp;
 import static org.apache.juneau.commons.utils.Shorts.*;
 
 import java.io.*;
+import java.time.*;
 
 import org.apache.juneau.rest.client.*;
 
@@ -122,9 +123,12 @@ public final class OkHttpTransport implements HttpTransport {
 
 	private static TransportResponse sendOnce(TransportRequest request, OkHttpClient client) throws TransportException {
 		var okRequest = buildOkRequest(request);
+		var timeout = request.getTimeout();
+		// Per-request read timeout (response-header wait and gaps between body reads); newBuilder() shares the connection pool.
+		var effective = timeout == null ? client : client.newBuilder().readTimeout(timeout).build();
 		Response okResponse;
 		try {
-			okResponse = client.newCall(okRequest).execute();
+			okResponse = effective.newCall(okRequest).execute();
 		} catch (IOException e) {
 			throw new TransportException("HTTP transport error: " + e.getMessage(), e);
 		}
diff --git a/juneau-rest/juneau-rest-client-okhttp/src/test/java/org/apache/juneau/rest/client/OkHttpTransport_RedirectCredentials_Test.java b/juneau-rest/juneau-rest-client-okhttp/src/test/java/org/apache/juneau/rest/client/OkHttpTransport_RedirectCredentials_Test.java
index d52e280224..7b4b18803a 100644
--- a/juneau-rest/juneau-rest-client-okhttp/src/test/java/org/apache/juneau/rest/client/OkHttpTransport_RedirectCredentials_Test.java
+++ b/juneau-rest/juneau-rest-client-okhttp/src/test/java/org/apache/juneau/rest/client/OkHttpTransport_RedirectCredentials_Test.java
@@ -36,6 +36,8 @@ import com.sun.net.httpserver.*;
 @SuppressWarnings({
 	"resource" // Transport/client instances are short-lived test fixtures.
 })
+// SEPARATE_THREAD: a socket read blocked on a non-responding peer ignores interrupts, so SAME_THREAD would still hang.
+@Timeout(value = 60, threadMode = Timeout.ThreadMode.SEPARATE_THREAD)
 class OkHttpTransport_RedirectCredentials_Test {
 
 	private static HttpServer serverA;
@@ -45,16 +47,18 @@ class OkHttpTransport_RedirectCredentials_Test {
 
 	@BeforeAll
 	static void startServers() throws IOException {
-		serverB = HttpServer.create(new InetSocketAddress(0), 0);
+		// Bind to (and connect via) the explicit IPv4 loopback, not the wildcard address + "localhost": a wildcard bind can be
+		// handed an ephemeral port another process already holds on 127.0.0.1 (SO_REUSEADDR), which then wins every "localhost" connection.
+		serverB = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
 		portB = serverB.getAddress().getPort();
 		serverB.createContext("/echo-creds", OkHttpTransport_RedirectCredentials_Test::echoCreds);
 		serverB.start();
 
-		serverA = HttpServer.create(new InetSocketAddress(0), 0);
+		serverA = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
 		portA = serverA.getAddress().getPort();
 		serverA.createContext("/echo-creds", OkHttpTransport_RedirectCredentials_Test::echoCreds);
-		serverA.createContext("/redirect-cross", exchange -> redirect(exchange, "http://localhost:" + portB + "/echo-creds"));
-		serverA.createContext("/redirect-same", exchange -> redirect(exchange, "http://localhost:" + portA + "/echo-creds"));
+		serverA.createContext("/redirect-cross", exchange -> redirect(exchange, "http://127.0.0.1:" + portB + "/echo-creds"));
+		serverA.createContext("/redirect-same", exchange -> redirect(exchange, "http://127.0.0.1:" + portA + "/echo-creds"));
 		serverA.start();
 	}
 
@@ -86,7 +90,7 @@ class OkHttpTransport_RedirectCredentials_Test {
 	@Test
 	void a01_crossOrigin_stripsCredentials() throws Exception {
 		var transport = OkHttpTransport.create();
-		try (var client = RestClient.builder().transport(transport).rootUrl("http://localhost:" + portA).build()) {
+		try (var client = RestClient.builder().transport(transport).rootUrl("http://127.0.0.1:" + portA).build()) {
 			try (var response = client.get("/redirect-cross")
 					.header("Authorization", "Bearer secret-token")
 					.header("Cookie", "session=abc123")
@@ -100,7 +104,7 @@ class OkHttpTransport_RedirectCredentials_Test {
 	@Test
 	void a02_sameOrigin_forwardsCredentials() throws Exception {
 		var transport = OkHttpTransport.create();
-		try (var client = RestClient.builder().transport(transport).rootUrl("http://localhost:" + portA).build()) {
+		try (var client = RestClient.builder().transport(transport).rootUrl("http://127.0.0.1:" + portA).build()) {
 			try (var response = client.get("/redirect-same")
 					.header("Authorization", "Bearer secret-token")
 					.header("Cookie", "session=abc123")
diff --git a/juneau-rest/juneau-rest-client-okhttp/src/test/java/org/apache/juneau/rest/client/OkHttpTransport_Test.java b/juneau-rest/juneau-rest-client-okhttp/src/test/java/org/apache/juneau/rest/client/OkHttpTransport_Test.java
index a85d4ac06c..77b6afb85e 100644
--- a/juneau-rest/juneau-rest-client-okhttp/src/test/java/org/apache/juneau/rest/client/OkHttpTransport_Test.java
+++ b/juneau-rest/juneau-rest-client-okhttp/src/test/java/org/apache/juneau/rest/client/OkHttpTransport_Test.java
@@ -36,6 +36,8 @@ import okhttp3.*;
 @SuppressWarnings({
 	"resource" // Transport/response Closeables are exercised inline; lifecycle is managed by the test/framework, not a real leak.
 })
+// SEPARATE_THREAD: a socket read blocked on a non-responding peer ignores interrupts, so SAME_THREAD would still hang.
+@Timeout(value = 60, threadMode = Timeout.ThreadMode.SEPARATE_THREAD)
 class OkHttpTransport_Test {
 
 	private static HttpServer server;
@@ -43,7 +45,9 @@ class OkHttpTransport_Test {
 
 	@BeforeAll
 	static void startServer() throws IOException {
-		server = HttpServer.create(new InetSocketAddress(0), 0);
+		// Bind to (and connect via) the explicit IPv4 loopback, not the wildcard address + "localhost": a wildcard bind can be
+		// handed an ephemeral port another process already holds on 127.0.0.1 (SO_REUSEADDR), which then wins every "localhost" connection.
+		server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
 		port = server.getAddress().getPort();
 
 		server.createContext("/hello", exchange -> {
@@ -99,7 +103,7 @@ class OkHttpTransport_Test {
 	}
 
 	private String rootUrl() {
-		return "http://localhost:" + port;
+		return "http://127.0.0.1:" + port;
 	}
 
 	// =================================================================================================================
diff --git a/juneau-rest/juneau-rest-client-okhttp/src/test/java/org/apache/juneau/rest/client/OkHttpTransport_Timeout_Test.java b/juneau-rest/juneau-rest-client-okhttp/src/test/java/org/apache/juneau/rest/client/OkHttpTransport_Timeout_Test.java
new file mode 100644
index 0000000000..54fdcf5162
--- /dev/null
+++ b/juneau-rest/juneau-rest-client-okhttp/src/test/java/org/apache/juneau/rest/client/OkHttpTransport_Timeout_Test.java
@@ -0,0 +1,111 @@
+/*
+ * 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.juneau.rest.client;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.io.*;
+import java.net.*;
+import java.nio.charset.*;
+import java.time.*;
+
+import org.apache.juneau.rest.client.*;
+import org.apache.juneau.rest.client.okhttp.*;
+import org.junit.jupiter.api.*;
+
+import com.sun.net.httpserver.*;
+
+/**
+ * Verifies that {@link TransportRequest#getTimeout()} is honored per request by {@code OkHttpTransport} (WORK-J0596).
+ *
+ * <p>
+ * Uses an embedded server whose {@code /slow} endpoint delays 2s before responding.
+ */
+@SuppressWarnings({
+	"java:S2925", // The server handler sleeps to simulate a slow upstream.
+	"resource" // Transport/response instances are short-lived test fixtures.
+})
+// SEPARATE_THREAD: a socket read blocked on a non-responding peer ignores interrupts, so SAME_THREAD would still hang.
+@Timeout(value = 60, threadMode = Timeout.ThreadMode.SEPARATE_THREAD)
+class OkHttpTransport_Timeout_Test {
+
+	private static HttpServer server;
+	private static int port;
+
+	@BeforeAll
+	static void startServer() throws IOException {
+		// Bind to (and connect via) the explicit IPv4 loopback, not the wildcard address + "localhost": a wildcard bind can be
+		// handed an ephemeral port another process already holds on 127.0.0.1 (SO_REUSEADDR), which then wins every "localhost" connection.
+		server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+		port = server.getAddress().getPort();
+		server.createContext("/slow", exchange -> {
+			try {
+				Thread.sleep(2000);
+				var body = "ok".getBytes(StandardCharsets.UTF_8);
+				exchange.sendResponseHeaders(200, body.length);
+				exchange.getResponseBody().write(body);
+			} catch (InterruptedException | IOException e) {
+				// Client gave up; nothing to do.
+			} finally {
+				exchange.close();
+			}
+		});
+		server.createContext("/fast", exchange -> {
+			var body = "ok".getBytes(StandardCharsets.UTF_8);
+			exchange.sendResponseHeaders(200, body.length);
+			exchange.getResponseBody().write(body);
+			exchange.close();
+		});
+		server.start();
+	}
+
+	@AfterAll
+	static void stopServer() {
+		server.stop(0);
+	}
+
+	private static TransportRequest request(String path, Duration timeout) {
+		return TransportRequest.builder().method("GET").uri("http://127.0.0.1:" + port + path).timeout(timeout).build();
+	}
+
+	@Test void a01_shortTimeout_timesOutQuickly() throws Exception {
+		try (var transport = OkHttpTransport.create()) {
+			var start = System.nanoTime();
+			var e = assertThrows(TransportException.class, () -> transport.execute(request("/slow", Duration.ofMillis(200))));
+			assertTrue(Duration.ofNanos(System.nanoTime() - start).toMillis() < 1800, "should time out well before the 2s server delay");
+			assertNotNull(e.getCause());
+		}
+	}
+
+	@Test void a02_noTimeout_waitsForResponse() throws Exception {
+		try (var transport = OkHttpTransport.create(); var response = transport.execute(request("/slow", null))) {
+			assertEquals(200, response.getStatusCode());
+		}
+	}
+
+	@Test void a03_longTimeout_succeeds() throws Exception {
+		try (var transport = OkHttpTransport.create(); var response = transport.execute(request("/slow", Duration.ofSeconds(10)))) {
+			assertEquals(200, response.getStatusCode());
+		}
+	}
+
+	@Test void a04_shortTimeout_fastEndpoint_succeeds() throws Exception {
+		try (var transport = OkHttpTransport.create(); var response = transport.execute(request("/fast", Duration.ofMillis(200)))) {
+			assertEquals(200, response.getStatusCode());
+		}
+	}
+}
diff --git a/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/JavaHttpTransport.java b/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/JavaHttpTransport.java
index a9def827e6..9d795c6d9e 100644
--- a/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/JavaHttpTransport.java
+++ b/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/JavaHttpTransport.java
@@ -98,7 +98,7 @@ public final class JavaHttpTransport implements HttpTransport {
 			System.setProperty(RESTRICTED_HEADERS_PROPERTY, "host");
 		else if (! existing.toLowerCase(Locale.ROOT).contains("host"))
 			System.setProperty(RESTRICTED_HEADERS_PROPERTY, existing + ",host");
-		// Q: Can we use commons settings for accessing system properties?
+		// Raw System property access is intentional: the JDK reads this value from System properties only, so Settings overrides/sources must not be consulted.
 	}
 
 	private final HttpClient httpClient;
@@ -273,7 +273,7 @@ public final class JavaHttpTransport implements HttpTransport {
 		// Use a pipe so body.writeTo() streams directly to the JDK client without full in-memory buffering.
 		// The writer runs on a daemon thread; the JDK client reads from the PipedInputStream on its own threads.
 		// The supplier runs when the client publishes the request body (not when the HttpRequest is built).
-		return BodyPublishers.ofInputStream(() -> {
+		var publisher = BodyPublishers.ofInputStream(() -> {
 			var in = new PipedInputStream();
 			try {
 				var out = new PipedOutputStream(in);
@@ -297,6 +297,12 @@ public final class JavaHttpTransport implements HttpTransport {
 				throw new UncheckedIOException(e);
 			}
 		});
+		// A repeatable body with a known length is sent fixed-length (Content-Length) rather than chunked;
+		// unknown-length (or non-repeatable streaming) bodies keep chunked transfer encoding.
+		var length = body.getContentLength();
+		if (body.isRepeatable() && length >= 0)
+			return length == 0 ? BodyPublishers.noBody() : BodyPublishers.fromPublisher(publisher, length);
+		return publisher;
 	}
 
 	private static TransportResponse buildTransportResponse(HttpResponse<InputStream> jdkResponse) {
diff --git a/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/RestRequest.java b/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/RestRequest.java
index 92404a8870..a3d6946e81 100644
--- a/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/RestRequest.java
+++ b/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/RestRequest.java
@@ -344,17 +344,26 @@ public final class RestRequest {
 	 * Sets a per-call response timeout for this request.
 	 *
 	 * <p>
-	 * Threaded onto the {@link TransportRequest}.  Connect timeouts remain a client-level setting.
+	 * Threaded onto the {@link TransportRequest}.  Intended as a response timeout; connect timeouts are otherwise a client-level setting.
 	 *
 	 * <p>
-	 * <b>Transport support:</b> currently only {@link JavaHttpTransport} applies this value (as the JDK
-	 * {@code HttpRequest} timeout).  The Apache HttpClient, OkHttp and Jetty transports do not consult it; configure
-	 * their own timeouts on the transport builder (e.g. {@code JettyHttpTransportBuilder.responseTimeoutMs(long)}).
+	 * <b>Transport support:</b> every built-in transport honors this value, using its native per-request mechanism:
+	 * the JDK {@code HttpRequest} timeout, an Apache HttpClient 4.5 {@code RequestConfig} (socket + connect timeout),
+	 * an Apache HttpClient 5 request-scoped response timeout, an OkHttp per-call read timeout, the Jetty response-header
+	 * wait, and a simulated handler deadline in the mock transport.  On expiry the transport throws a
+	 * {@link TransportException}.  When set, it overrides the transport's own default for this request only.
+	 *
+	 * <p>
+	 * A non-<jk>null</jk> value must be positive; zero and negative durations are rejected (matching the JDK
+	 * {@code HttpRequest.timeout} contract) so every transport behaves identically.
 	 *
 	 * @param value The response timeout. May be <jk>null</jk> to use the transport default.
 	 * @return This object.
+	 * @throws IllegalArgumentException If <c>value</c> is zero or negative.
 	 */
 	public RestRequest timeout(Duration value) {
+		if (value != null && (value.isZero() || value.isNegative()))
+			throw new IllegalArgumentException("Request timeout must be positive: " + value);
 		timeout = value;
 		return this;
 	}
diff --git a/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/TransportRequest.java b/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/TransportRequest.java
index 7bbffefae6..efcf1f8554 100644
--- a/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/TransportRequest.java
+++ b/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/TransportRequest.java
@@ -148,7 +148,7 @@ public final class TransportRequest {
 	 *
 	 * <p>
 	 * A {@code null} value means the transport's own default applies.  Connect timeouts remain a client-level
-	 * concern.  Currently only {@link JavaHttpTransport} honors this value; other transports ignore it.
+	 * concern.  All built-in transports honor this value (JDK, Apache HttpClient 4.5/5, OkHttp, Jetty and the mock transport).
 	 *
 	 * @return The response timeout, possibly <jk>null</jk>.
 	 */
@@ -291,10 +291,13 @@ public final class TransportRequest {
 		/**
 		 * Sets the per-call response timeout.
 		 *
-		 * @param value The response timeout. May be <jk>null</jk> to use the transport default.
+		 * @param value The response timeout. May be <jk>null</jk> to use the transport default.  Must otherwise be positive.
 		 * @return This object.
+		 * @throws IllegalArgumentException If <c>value</c> is zero or negative.
 		 */
 		public Builder timeout(Duration value) {
+			if (value != null && (value.isZero() || value.isNegative()))
+				throw new IllegalArgumentException("Request timeout must be positive: " + value);
 			timeout = value;
 			return this;
 		}
diff --git a/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/remote/RemoteClient.java b/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/remote/RemoteClient.java
index 9eccfa86f4..c6b5600dd2 100644
--- a/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/remote/RemoteClient.java
+++ b/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/remote/RemoteClient.java
@@ -1149,9 +1149,10 @@ public final class RemoteClient {
 				|| arg instanceof Character || arg instanceof Enum || isDateTime(arg);
 		}
 
-		/** Returns <jk>true</jk> for {@link java.util.Date} and {@code java.time} values (Instant, LocalDate, Duration, Period, ...), which are scalars. */
+		/** Returns <jk>true</jk> for {@link java.util.Date}, {@link java.util.Calendar} and {@code java.time} values (Instant, LocalDate, Duration, Period, ...), which are scalars. */
 		private static boolean isDateTime(Object arg) {
-			return arg instanceof java.util.Date || arg instanceof java.time.temporal.Temporal || arg instanceof java.time.temporal.TemporalAmount;
+			// Q: Don't use FQCNs here.
+			return arg instanceof java.util.Date || arg instanceof java.util.Calendar || arg instanceof java.time.temporal.Temporal || arg instanceof java.time.temporal.TemporalAmount;
 		}
 
 		/**
diff --git a/juneau-rest/juneau-rest-client/src/test/java/org/apache/juneau/rest/client/JavaHttpTransport_StaleConnectionRetry_Test.java b/juneau-rest/juneau-rest-client/src/test/java/org/apache/juneau/rest/client/JavaHttpTransport_StaleConnectionRetry_Test.java
index b4be5012e2..5f1a0bde4c 100644
--- a/juneau-rest/juneau-rest-client/src/test/java/org/apache/juneau/rest/client/JavaHttpTransport_StaleConnectionRetry_Test.java
+++ b/juneau-rest/juneau-rest-client/src/test/java/org/apache/juneau/rest/client/JavaHttpTransport_StaleConnectionRetry_Test.java
@@ -40,6 +40,9 @@ import org.junit.jupiter.api.*;
 @SuppressWarnings({
 	"resource" // Transport/client instances are short-lived test fixtures.
 })
+// SEPARATE_THREAD: a socket read blocked on a non-responding peer ignores interrupts, so SAME_THREAD would still hang.
+// Fixtures bind/connect via explicit 127.0.0.1: a wildcard bind can be handed a port another process holds on 127.0.0.1.
+@Timeout(value = 60, threadMode = Timeout.ThreadMode.SEPARATE_THREAD)
 class JavaHttpTransport_StaleConnectionRetry_Test {
 
 	// =================================================================================================================
@@ -90,14 +93,14 @@ class JavaHttpTransport_StaleConnectionRetry_Test {
 
 		A01_StaleServer(int staleConnections) throws IOException {
 			this.staleConnections = staleConnections;
-			this.serverSocket = new ServerSocket(0);
+			this.serverSocket = new ServerSocket(0, 50, InetAddress.getByName("127.0.0.1"));
 			var acceptThread = new Thread(this::acceptLoop, "java-stale-server");
 			acceptThread.setDaemon(true);
 			acceptThread.start();
 		}
 
 		String rootUrl() {
-			return "http://localhost:" + serverSocket.getLocalPort();
+			return "http://127.0.0.1:" + serverSocket.getLocalPort();
 		}
 
 		int connectionCount() {
diff --git a/juneau-rest/juneau-rest-client/src/test/java/org/apache/juneau/rest/client/JavaHttpTransport_Test.java b/juneau-rest/juneau-rest-client/src/test/java/org/apache/juneau/rest/client/JavaHttpTransport_Test.java
index 338523c4ea..88e95ab474 100644
--- a/juneau-rest/juneau-rest-client/src/test/java/org/apache/juneau/rest/client/JavaHttpTransport_Test.java
+++ b/juneau-rest/juneau-rest-client/src/test/java/org/apache/juneau/rest/client/JavaHttpTransport_Test.java
@@ -16,6 +16,7 @@
  */
 package org.apache.juneau.rest.client;
 
+import static org.apache.juneau.test.bct.BctAssertions.*;
 import static org.junit.jupiter.api.Assertions.*;
 
 import java.io.*;
@@ -43,6 +44,8 @@ import com.sun.net.httpserver.*;
 @SuppressWarnings({
 	"resource" // Transport/client instances are short-lived test fixtures; closing is irrelevant to these assertions.
 })
+// SEPARATE_THREAD: a socket read blocked on a non-responding peer ignores interrupts, so SAME_THREAD would still hang.
+@Timeout(value = 60, threadMode = Timeout.ThreadMode.SEPARATE_THREAD)
 class JavaHttpTransport_Test extends TestBase {
 
 	private static HttpServer server;
@@ -50,7 +53,9 @@ class JavaHttpTransport_Test extends TestBase {
 
 	@BeforeAll
 	static void startServer() throws IOException {
-		server = HttpServer.create(new InetSocketAddress(0), 0);
+		// Bind to (and connect via) the explicit IPv4 loopback, not the wildcard address + "localhost": a wildcard bind can be
+		// handed an ephemeral port another process already holds on 127.0.0.1 (SO_REUSEADDR), which then wins every "localhost" connection.
+		server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
 		port = server.getAddress().getPort();
 
 		server.createContext("/hello", exchange -> {
@@ -86,6 +91,17 @@ class JavaHttpTransport_Test extends TestBase {
 			exchange.close();
 		});
 
+		// Reports the request framing headers actually seen on the wire, plus the received body.
+		server.createContext("/echo-framing", exchange -> {
+			var h = exchange.getRequestHeaders();
+			var requestBody = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);
+			var out = ("Content-Length=" + h.getFirst("Content-Length") + "\nTransfer-Encoding=" + h.getFirst("Transfer-Encoding") + "\nbody=" + requestBody).getBytes(StandardCharsets.UTF_8);
+			exchange.getResponseHeaders().add("Content-Type", "text/plain");
+			exchange.sendResponseHeaders(200, out.length);
+			exchange.getResponseBody().write(out);
+			exchange.close();
+		});
+
 		server.createContext("/not-found", exchange -> {
 			exchange.sendResponseHeaders(404, -1);
 			exchange.close();
@@ -101,7 +117,7 @@ class JavaHttpTransport_Test extends TestBase {
 	}
 
 	private String rootUrl() {
-		return "http://localhost:" + port;
+		return "http://127.0.0.1:" + port;
 	}
 
 	// =================================================================================================================
@@ -395,4 +411,39 @@ class JavaHttpTransport_Test extends TestBase {
 		}
 		assertTrue(bodyClosed.isSet());
 	}
+
+	// =================================================================================================================
+	// E — Request framing (Content-Length vs. chunked)
+	// =================================================================================================================
+
+	private List<String> framing(org.apache.juneau.http.HttpBody body) throws Exception {
+		var transport = JavaHttpTransport.create();
+		try (var client = RestClient.builder().transport(transport).rootUrl(rootUrl()).build()) {
+			try (var response = client.post("/echo-framing").body(body).run()) {
+				assertEquals(200, response.getStatusCode());
+				return List.of(response.getBodyAsString().split("\n", -1));
+			}
+		}
+	}
+
+	@Test
+	void e01_knownLengthRepeatableBody_sendsContentLengthNotChunked() throws Exception {
+		assertList(framing(StringBody.of("hello body", "text/plain")), "Content-Length=10", "Transfer-Encoding=null", "body=hello body");
+	}
+
+	@Test
+	void e02_knownLengthByteArrayBody_sendsContentLengthNotChunked() throws Exception {
+		assertList(framing(ByteArrayBody.of("bytes!".getBytes(StandardCharsets.UTF_8), "application/octet-stream")), "Content-Length=6", "Transfer-Encoding=null", "body=bytes!");
+	}
+
+	@Test
+	void e03_emptyKnownLengthBody_sendsZeroContentLength() throws Exception {
+		assertList(framing(StringBody.of("", "text/plain")), "Content-Length=0", "Transfer-Encoding=null", "body=");
+	}
+
+	@Test
+	void e04_unknownLengthStreamBody_staysChunked() throws Exception {
+		var stream = new ByteArrayInputStream("streamed".getBytes(StandardCharsets.UTF_8));
+		assertList(framing(StreamBody.of(stream, "text/plain")), "Content-Length=null", "Transfer-Encoding=chunked", "body=streamed");
+	}
 }
diff --git a/juneau-rest/juneau-rest-client/src/test/java/org/apache/juneau/rest/client/JavaHttpTransport_Timeout_Test.java b/juneau-rest/juneau-rest-client/src/test/java/org/apache/juneau/rest/client/JavaHttpTransport_Timeout_Test.java
new file mode 100644
index 0000000000..5a36f03209
--- /dev/null
+++ b/juneau-rest/juneau-rest-client/src/test/java/org/apache/juneau/rest/client/JavaHttpTransport_Timeout_Test.java
@@ -0,0 +1,111 @@
+/*
+ * 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.juneau.rest.client;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.io.*;
+import java.net.*;
+import java.nio.charset.*;
+import java.time.*;
+
+import org.apache.juneau.rest.client.*;
+
+import org.junit.jupiter.api.*;
+
+import com.sun.net.httpserver.*;
+
+/**
+ * Verifies that {@link TransportRequest#getTimeout()} is honored per request by {@code JavaHttpTransport} (WORK-J0596).
+ *
+ * <p>
+ * Uses an embedded server whose {@code /slow} endpoint delays 2s before responding.
+ */
+@SuppressWarnings({
+	"java:S2925", // The server handler sleeps to simulate a slow upstream.
+	"resource" // Transport/response instances are short-lived test fixtures.
+})
+// SEPARATE_THREAD: a socket read blocked on a non-responding peer ignores interrupts, so SAME_THREAD would still hang.
+@Timeout(value = 60, threadMode = Timeout.ThreadMode.SEPARATE_THREAD)
+class JavaHttpTransport_Timeout_Test {
+
+	private static HttpServer server;
+	private static int port;
+
+	@BeforeAll
+	static void startServer() throws IOException {
+		// Bind to (and connect via) the explicit IPv4 loopback, not the wildcard address + "localhost": a wildcard bind can be
+		// handed an ephemeral port another process already holds on 127.0.0.1 (SO_REUSEADDR), which then wins every "localhost" connection.
+		server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+		port = server.getAddress().getPort();
+		server.createContext("/slow", exchange -> {
+			try {
+				Thread.sleep(2000);
+				var body = "ok".getBytes(StandardCharsets.UTF_8);
+				exchange.sendResponseHeaders(200, body.length);
+				exchange.getResponseBody().write(body);
+			} catch (InterruptedException | IOException e) {
+				// Client gave up; nothing to do.
+			} finally {
+				exchange.close();
+			}
+		});
+		server.createContext("/fast", exchange -> {
+			var body = "ok".getBytes(StandardCharsets.UTF_8);
+			exchange.sendResponseHeaders(200, body.length);
+			exchange.getResponseBody().write(body);
+			exchange.close();
+		});
+		server.start();
+	}
+
+	@AfterAll
+	static void stopServer() {
+		server.stop(0);
+	}
+
+	private static TransportRequest request(String path, Duration timeout) {
+		return TransportRequest.builder().method("GET").uri("http://127.0.0.1:" + port + path).timeout(timeout).build();
+	}
+
+	@Test void a01_shortTimeout_timesOutQuickly() throws Exception {
+		try (var transport = JavaHttpTransport.create()) {
+			var start = System.nanoTime();
+			var e = assertThrows(TransportException.class, () -> transport.execute(request("/slow", Duration.ofMillis(200))));
+			assertTrue(Duration.ofNanos(System.nanoTime() - start).toMillis() < 1800, "should time out well before the 2s server delay");
+			assertNotNull(e.getCause());
+		}
+	}
+
+	@Test void a02_noTimeout_waitsForResponse() throws Exception {
+		try (var transport = JavaHttpTransport.create(); var response = transport.execute(request("/slow", null))) {
+			assertEquals(200, response.getStatusCode());
+		}
+	}
+
+	@Test void a03_longTimeout_succeeds() throws Exception {
+		try (var transport = JavaHttpTransport.create(); var response = transport.execute(request("/slow", Duration.ofSeconds(10)))) {
+			assertEquals(200, response.getStatusCode());
+		}
+	}
+
+	@Test void a04_shortTimeout_fastEndpoint_succeeds() throws Exception {
+		try (var transport = JavaHttpTransport.create(); var response = transport.execute(request("/fast", Duration.ofMillis(200)))) {
+			assertEquals(200, response.getStatusCode());
+		}
+	}
+}
diff --git a/juneau-rest/juneau-rest-client/src/test/java/org/apache/juneau/rest/client/RemoteClient_Test.java b/juneau-rest/juneau-rest-client/src/test/java/org/apache/juneau/rest/client/RemoteClient_Test.java
index 961958ee1d..bfa5d31706 100644
--- a/juneau-rest/juneau-rest-client/src/test/java/org/apache/juneau/rest/client/RemoteClient_Test.java
+++ b/juneau-rest/juneau-rest-client/src/test/java/org/apache/juneau/rest/client/RemoteClient_Test.java
@@ -41,6 +41,8 @@ import com.sun.net.httpserver.*;
 @SuppressWarnings({
 	"resource" // RestClient/RestResponse instances used inline; closed via try-with-resources where needed.
 })
+// SEPARATE_THREAD: a socket read blocked on a non-responding peer ignores interrupts, so SAME_THREAD would still hang.
+@Timeout(value = 60, threadMode = Timeout.ThreadMode.SEPARATE_THREAD)
 class RemoteClient_Test extends TestBase {
 
 	// -----------------------------------------------------------------------
@@ -52,7 +54,9 @@ class RemoteClient_Test extends TestBase {
 
 	@BeforeAll
 	static void startServer() throws IOException {
-		server = HttpServer.create(new InetSocketAddress(0), 0);
+		// Bind to (and connect via) the explicit IPv4 loopback, not the wildcard address + "localhost": a wildcard bind can be
+		// handed an ephemeral port another process already holds on 127.0.0.1 (SO_REUSEADDR), which then wins every "localhost" connection.
+		server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
 		port = server.getAddress().getPort();
 
 		// /hello — echoes "Hello!" as plain text
@@ -134,7 +138,7 @@ class RemoteClient_Test extends TestBase {
 	}
 
 	private String rootUrl() {
-		return "http://localhost:" + port;
+		return "http://127.0.0.1:" + port;
 	}
 
 	// -----------------------------------------------------------------------
diff --git a/juneau-rest/juneau-rest-client/src/test/java/org/apache/juneau/rest/client/RestRequest_Timeout_Test.java b/juneau-rest/juneau-rest-client/src/test/java/org/apache/juneau/rest/client/RestRequest_Timeout_Test.java
new file mode 100644
index 0000000000..70573f919f
--- /dev/null
+++ b/juneau-rest/juneau-rest-client/src/test/java/org/apache/juneau/rest/client/RestRequest_Timeout_Test.java
@@ -0,0 +1,61 @@
+/*
+ * 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.juneau.rest.client;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.time.*;
+
+import org.junit.jupiter.api.*;
+
+/**
+ * Verifies the positive-duration rule for per-request timeouts (WORK-J0596).
+ */
+@SuppressWarnings({
+	"resource" // Client is a short-lived test fixture.
+})
+class RestRequest_Timeout_Test {
+
+	private static RestClient client() {
+		return RestClient.builder().transport(req -> TransportResponse.builder().statusCode(200).build()).build();
+	}
+
+	@Test void a01_zeroTimeout_rejected() throws Exception {
+		try (var c = client()) {
+			var e = assertThrows(IllegalArgumentException.class, () -> c.get("http://x/").timeout(Duration.ZERO));
+			assertTrue(e.getMessage().contains("must be positive"));
+		}
+	}
+
+	@Test void a02_negativeTimeout_rejected() throws Exception {
+		try (var c = client()) {
+			assertThrows(IllegalArgumentException.class, () -> c.get("http://x/").timeout(Duration.ofMillis(-1)));
+		}
+	}
+
+	@Test void a03_nullAndPositive_accepted() throws Exception {
+		try (var c = client()) {
+			assertDoesNotThrow(() -> c.get("http://x/").timeout(null));
+			assertDoesNotThrow(() -> c.get("http://x/").timeout(Duration.ofMillis(1)));
+		}
+	}
+
+	@Test void a04_transportRequestBuilder_rejectsNonPositive() {
+		assertThrows(IllegalArgumentException.class, () -> TransportRequest.builder().timeout(Duration.ZERO));
+		assertThrows(IllegalArgumentException.class, () -> TransportRequest.builder().timeout(Duration.ofSeconds(-1)));
+	}
+}
diff --git a/juneau-rest/juneau-rest-client/src/test/java/org/apache/juneau/rest/client/remote/RemoteClient_InvocationHandlerInternals_Test.java b/juneau-rest/juneau-rest-client/src/test/java/org/apache/juneau/rest/client/remote/RemoteClient_InvocationHandlerInternals_Test.java
index f11c08f1e6..824f1a25a2 100644
--- a/juneau-rest/juneau-rest-client/src/test/java/org/apache/juneau/rest/client/remote/RemoteClient_InvocationHandlerInternals_Test.java
+++ b/juneau-rest/juneau-rest-client/src/test/java/org/apache/juneau/rest/client/remote/RemoteClient_InvocationHandlerInternals_Test.java
@@ -354,6 +354,20 @@ class RemoteClient_InvocationHandlerInternals_Test extends TestBase {
 			assertEquals(true, invokeStatic("isScalarPart", new Class<?>[]{Object.class}, v), v.getClass().getName());
 	}
 
+	@Test void j03c_isScalarPart_calendarTypes_true() throws Exception {
+		for (var v : calendarValues())
+			assertEquals(true, invokeStatic("isScalarPart", new Class<?>[]{Object.class}, v), v.getClass().getName());
+	}
+
+	@Test void e06c_isBean_calendarTypes_false() throws Exception {
+		for (var v : calendarValues())
+			assertEquals(false, invokeStatic("isBean", new Class<?>[]{Object.class}, v), v.getClass().getName());
+	}
+
+	private static Object[] calendarValues() {
+		return new Object[]{ java.util.Calendar.getInstance(), new java.util.GregorianCalendar(2026, 0, 2) };
+	}
+
 	@Test void j04_isScalarPart_nonScalar_false() throws Exception {
 		assertEquals(false, invokeStatic("isScalarPart", new Class<?>[]{Object.class}, List.of("x")));
 		assertEquals(false, invokeStatic("isScalarPart", new Class<?>[]{Object.class}, new Object()));
diff --git a/juneau-rest/juneau-rest-client/src/test/java/org/apache/juneau/rest/client/remote/RemoteClient_ParseAndCursor_Test.java b/juneau-rest/juneau-rest-client/src/test/java/org/apache/juneau/rest/client/remote/RemoteClient_ParseAndCursor_Test.java
index f9de944113..0176ca5c1a 100644
--- a/juneau-rest/juneau-rest-client/src/test/java/org/apache/juneau/rest/client/remote/RemoteClient_ParseAndCursor_Test.java
+++ b/juneau-rest/juneau-rest-client/src/test/java/org/apache/juneau/rest/client/remote/RemoteClient_ParseAndCursor_Test.java
@@ -42,6 +42,8 @@ import com.sun.net.httpserver.*;
 @SuppressWarnings({
 	"resource" // RestClient/RestResponse instances used inline; closed via try-with-resources where needed.
 })
+// SEPARATE_THREAD: a socket read blocked on a non-responding peer ignores interrupts, so SAME_THREAD would still hang.
+@Timeout(value = 60, threadMode = Timeout.ThreadMode.SEPARATE_THREAD)
 class RemoteClient_ParseAndCursor_Test extends TestBase {
 
 	private static HttpServer server;
@@ -55,7 +57,9 @@ class RemoteClient_ParseAndCursor_Test extends TestBase {
 
 	@BeforeAll
 	static void startServer() throws IOException {
-		server = HttpServer.create(new InetSocketAddress(0), 0);
+		// Bind to (and connect via) the explicit IPv4 loopback, not the wildcard address + "localhost": a wildcard bind can be
+		// handed an ephemeral port another process already holds on 127.0.0.1 (SO_REUSEADDR), which then wins every "localhost" connection.
+		server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
 		port = server.getAddress().getPort();
 
 		server.createContext("/widget", exchange -> {
@@ -111,7 +115,7 @@ class RemoteClient_ParseAndCursor_Test extends TestBase {
 	}
 
 	private String rootUrl() {
-		return "http://localhost:" + port;
+		return "http://127.0.0.1:" + port;
 	}
 
 	// -----------------------------------------------------------------------
diff --git a/juneau-rest/juneau-rest-client/src/test/java/org/apache/juneau/rest/client/remote/RemoteClient_ReturnModesAndRetry_Test.java b/juneau-rest/juneau-rest-client/src/test/java/org/apache/juneau/rest/client/remote/RemoteClient_ReturnModesAndRetry_Test.java
index 0b78b6a5bf..e74ed2728b 100644
--- a/juneau-rest/juneau-rest-client/src/test/java/org/apache/juneau/rest/client/remote/RemoteClient_ReturnModesAndRetry_Test.java
+++ b/juneau-rest/juneau-rest-client/src/test/java/org/apache/juneau/rest/client/remote/RemoteClient_ReturnModesAndRetry_Test.java
@@ -42,6 +42,8 @@ import com.sun.net.httpserver.*;
 @SuppressWarnings({
 	"resource" // RestClient/RestResponse instances used inline; closed via try-with-resources where needed.
 })
+// SEPARATE_THREAD: a socket read blocked on a non-responding peer ignores interrupts, so SAME_THREAD would still hang.
+@Timeout(value = 60, threadMode = Timeout.ThreadMode.SEPARATE_THREAD)
 class RemoteClient_ReturnModesAndRetry_Test extends TestBase {
 
 	private static HttpServer server;
@@ -51,7 +53,9 @@ class RemoteClient_ReturnModesAndRetry_Test extends TestBase {
 
 	@BeforeAll
 	static void startServer() throws IOException {
-		server = HttpServer.create(new InetSocketAddress(0), 0);
+		// Bind to (and connect via) the explicit IPv4 loopback, not the wildcard address + "localhost": a wildcard bind can be
+		// handed an ephemeral port another process already holds on 127.0.0.1 (SO_REUSEADDR), which then wins every "localhost" connection.
+		server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
 		port = server.getAddress().getPort();
 
 		server.createContext("/hello", exchange -> {
@@ -124,7 +128,7 @@ class RemoteClient_ReturnModesAndRetry_Test extends TestBase {
 	}
 
 	private String rootUrl() {
-		return "http://localhost:" + port;
+		return "http://127.0.0.1:" + port;
 	}
 
 	// -----------------------------------------------------------------------
diff --git a/juneau-rest/juneau-rest-mock/src/main/java/org/apache/juneau/rest/mock/MockHttpTransport.java b/juneau-rest/juneau-rest-mock/src/main/java/org/apache/juneau/rest/mock/MockHttpTransport.java
index 54aa1de822..8d900a3821 100644
--- a/juneau-rest/juneau-rest-mock/src/main/java/org/apache/juneau/rest/mock/MockHttpTransport.java
+++ b/juneau-rest/juneau-rest-mock/src/main/java/org/apache/juneau/rest/mock/MockHttpTransport.java
@@ -115,8 +115,38 @@ public final class MockHttpTransport implements HttpTransport {
 	@Override /* HttpTransport */
 	public TransportResponse execute(TransportRequest request) throws TransportException {
 		if (request.isSsrfGuardActive())
-			return PolicyEnforcedRedirects.execute(request, this::dispatch);
-		return dispatch(request);
+			return PolicyEnforcedRedirects.execute(request, this::dispatchWithTimeout);
+		return dispatchWithTimeout(request);
+	}
+
+	// Simulates TransportRequest.getTimeout(): when set, the matched handler runs on a daemon thread and the call fails
+	// with a TransportException (cause: TimeoutException) if the handler has not returned within the timeout.  With no
+	// timeout the handler runs inline on the calling thread, exactly as before.
+	private TransportResponse dispatchWithTimeout(TransportRequest request) throws TransportException {
+		var timeout = request.getTimeout();
+		if (timeout == null)
+			return dispatch(request);
+		var task = new FutureTask<TransportResponse>(() -> dispatch(request));
+		var thread = new Thread(task, "juneau-mock-transport");
+		thread.setDaemon(true);
+		thread.start();
+		try {
+			return task.get(Math.max(1, timeout.toMillis()), TimeUnit.MILLISECONDS);
+		} catch (TimeoutException e) {
+			task.cancel(true);
+			throw new TransportException("HTTP request timed out", e);
+		} catch (InterruptedException e) {
+			task.cancel(true);
+			Thread.currentThread().interrupt();
+			throw new TransportException("HTTP request interrupted", e);
+		} catch (ExecutionException e) {
+			var cause = e.getCause();
+			if (cause instanceof TransportException te)
+				throw te;
+			if (cause instanceof RuntimeException re)
+				throw re;
+			throw new TransportException("HTTP transport error: " + cause.getMessage(), cause);
+		}
 	}
 
 	@Override /* HttpTransport */
diff --git a/juneau-rest/juneau-rest-mock/src/test/java/org/apache/juneau/rest/mock/MockHttpTransport_Timeout_Test.java b/juneau-rest/juneau-rest-mock/src/test/java/org/apache/juneau/rest/mock/MockHttpTransport_Timeout_Test.java
new file mode 100644
index 0000000000..8f1603e26a
--- /dev/null
+++ b/juneau-rest/juneau-rest-mock/src/test/java/org/apache/juneau/rest/mock/MockHttpTransport_Timeout_Test.java
@@ -0,0 +1,79 @@
+/*
+ * 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.juneau.rest.mock;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.io.*;
+import java.nio.charset.*;
+import java.time.*;
+import java.util.concurrent.*;
+
+import org.apache.juneau.rest.client.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Verifies that {@link MockHttpTransport} simulates {@link TransportRequest#getTimeout()} (WORK-J0596).
+ */
+@SuppressWarnings({
+	"java:S2925", // The handler sleeps to simulate a slow upstream.
+	"resource" // Transport/response instances are short-lived test fixtures.
+})
+class MockHttpTransport_Timeout_Test {
+
+	private static MockHttpTransport slowTransport() {
+		return MockHttpTransport.builder().fallback((MockHttpTransport.RequestHandler)req -> {
+			try {
+				Thread.sleep(2000);
+			} catch (InterruptedException e) {
+				Thread.currentThread().interrupt();
+			}
+			return TransportResponse.builder().statusCode(200).body(new ByteArrayInputStream("ok".getBytes(StandardCharsets.UTF_8))).build();
+		}).build();
+	}
+
+	private static TransportRequest request(Duration timeout) {
+		return TransportRequest.builder().method("GET").uri("http://localhost/slow").timeout(timeout).build();
+	}
+
+	@Test void a01_shortTimeout_throwsTransportException() {
+		var start = System.nanoTime();
+		var e = assertThrows(TransportException.class, () -> slowTransport().execute(request(Duration.ofMillis(200))));
+		assertInstanceOf(TimeoutException.class, e.getCause());
+		assertTrue(Duration.ofNanos(System.nanoTime() - start).toMillis() < 1800);
+	}
+
+	@Test void a02_noTimeout_runsInline() throws Exception {
+		try (var response = slowTransport().execute(request(null))) {
+			assertEquals(200, response.getStatusCode());
+		}
+	}
+
+	@Test void a03_longTimeout_succeeds() throws Exception {
+		try (var response = slowTransport().execute(request(Duration.ofSeconds(10)))) {
+			assertEquals(200, response.getStatusCode());
+		}
+	}
+
+	@Test void a04_handlerTransportException_propagatesUnchanged() {
+		var transport = MockHttpTransport.builder().fallback((MockHttpTransport.RequestHandler)req -> {
+			throw new TransportException("boom");
+		}).build();
+		var e = assertThrows(TransportException.class, () -> transport.execute(request(Duration.ofSeconds(5))));
+		assertEquals("boom", e.getMessage());
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-mcp/src/main/java/org/apache/juneau/rest/server/mcp/AbstractMcpRestServlet.java b/juneau-rest/juneau-rest-server-mcp/src/main/java/org/apache/juneau/rest/server/mcp/AbstractMcpRestServlet.java
index d07fd73fed..f292f47e1a 100644
--- a/juneau-rest/juneau-rest-server-mcp/src/main/java/org/apache/juneau/rest/server/mcp/AbstractMcpRestServlet.java
+++ b/juneau-rest/juneau-rest-server-mcp/src/main/java/org/apache/juneau/rest/server/mcp/AbstractMcpRestServlet.java
@@ -23,6 +23,7 @@ import org.apache.juneau.commons.inject.*;
 import org.apache.juneau.http.Content;
 import org.apache.juneau.marshall.serializer.*;
 import org.apache.juneau.rest.server.*;
+import org.apache.juneau.rest.server.health.*;
 import org.apache.juneau.rest.server.servlet.*;
 
 /**
@@ -57,7 +58,7 @@ import org.apache.juneau.rest.server.servlet.*;
  */
 @Rest
 @SerializerConfig(addBeanTypes = "true", uriResolution = "NONE")
-public abstract class AbstractMcpRestServlet extends BasicRestServlet {
+public abstract class AbstractMcpRestServlet extends BasicRestServlet implements ReadinessStateAware {
 	private static final long serialVersionUID = 1L;
 
 	private final transient AtomicReference<McpServerConfig> config = new AtomicReference<>();
@@ -131,6 +132,26 @@ public abstract class AbstractMcpRestServlet extends BasicRestServlet {
 		return null;
 	}
 
+	/**
+	 * Closes this servlet's open subscription streams as soon as the embedded server begins shutting down.
+	 *
+	 * <p>
+	 * A {@code subscriptions/listen} stream never finishes on its own, so without this the server's graceful
+	 * drain would wait out its entire stop timeout (30s by default) for every connected subscriber before it
+	 * could stop.  Closing the subscriptions makes each stream send its terminal frame and complete, letting
+	 * the drain finish immediately.
+	 *
+	 * @param state The per-service readiness state published by the embedded-server lifecycle component.
+	 */
+	@Override /* ReadinessStateAware */
+	public void acceptReadinessState(ReadinessState state) {
+		state.onOutOfService(this, () -> {
+			var broker = getSubscriptionBroker();
+			if (broker != null)
+				broker.closeAll();
+		});
+	}
+
 	/**
 	 * MCP JSON-RPC endpoint.
 	 *
diff --git a/juneau-rest/juneau-rest-server-mcp/src/main/java/org/apache/juneau/rest/server/mcp/BasicMcpSubscriptionBroker.java b/juneau-rest/juneau-rest-server-mcp/src/main/java/org/apache/juneau/rest/server/mcp/BasicMcpSubscriptionBroker.java
index 9b426e7a5b..b1c999b69f 100644
--- a/juneau-rest/juneau-rest-server-mcp/src/main/java/org/apache/juneau/rest/server/mcp/BasicMcpSubscriptionBroker.java
+++ b/juneau-rest/juneau-rest-server-mcp/src/main/java/org/apache/juneau/rest/server/mcp/BasicMcpSubscriptionBroker.java
@@ -105,6 +105,13 @@ public class BasicMcpSubscriptionBroker implements McpSubscriptionBroker {
 		return subscriptions.size();
 	}
 
+	@Override
+	public void closeAll() {
+		// close() removes the entry via the close callback; iterating a snapshot of the weakly-consistent view is safe.
+		for (var sub : subscriptions.values())
+			sub.close();
+	}
+
 	@Override
 	public void resourceUpdated(String uri) {
 		if (uri == null)
diff --git a/juneau-rest/juneau-rest-server-mcp/src/main/java/org/apache/juneau/rest/server/mcp/McpSubscriptionBroker.java b/juneau-rest/juneau-rest-server-mcp/src/main/java/org/apache/juneau/rest/server/mcp/McpSubscriptionBroker.java
index 063666e61a..0eed289406 100644
--- a/juneau-rest/juneau-rest-server-mcp/src/main/java/org/apache/juneau/rest/server/mcp/McpSubscriptionBroker.java
+++ b/juneau-rest/juneau-rest-server-mcp/src/main/java/org/apache/juneau/rest/server/mcp/McpSubscriptionBroker.java
@@ -86,4 +86,17 @@ public interface McpSubscriptionBroker extends McpSubscriptions {
 	 * @return The active count.
 	 */
 	int activeCount();
+
+	/**
+	 * Closes every currently registered subscription.
+	 *
+	 * <p>
+	 * Called when the server begins shutting down, so open {@code subscriptions/listen} streams complete
+	 * promptly instead of holding the graceful drain open until its stop timeout.  The default implementation
+	 * does nothing, so custom brokers that predate this method keep working (their streams then end on the
+	 * normal heartbeat/idle-timeout paths).
+	 */
+	default void closeAll() {
+		// No-op by default.
+	}
 }
diff --git a/juneau-rest/juneau-rest-server-mcp/src/test/java/org/apache/juneau/rest/server/mcp/BasicMcpSubscriptionBroker_Test.java b/juneau-rest/juneau-rest-server-mcp/src/test/java/org/apache/juneau/rest/server/mcp/BasicMcpSubscriptionBroker_Test.java
index a308fd072f..3775578b31 100644
--- a/juneau-rest/juneau-rest-server-mcp/src/test/java/org/apache/juneau/rest/server/mcp/BasicMcpSubscriptionBroker_Test.java
+++ b/juneau-rest/juneau-rest-server-mcp/src/test/java/org/apache/juneau/rest/server/mcp/BasicMcpSubscriptionBroker_Test.java
@@ -59,6 +59,17 @@ class BasicMcpSubscriptionBroker_Test {
 		assertDoesNotThrow(() -> broker.unregister("nope"));
 	}
 
+	@Test void a03b_closeAll_closesAndRemovesEverySubscription() {
+		var broker = new BasicMcpSubscriptionBroker(4);
+		try (var s1 = broker.register("s1", new McpSubscriptionFilter(true, true, true, Set.of()));
+			var s2 = broker.register("s2", new McpSubscriptionFilter(true, true, true, Set.of()))) {
+			broker.closeAll();
+			assertEquals(0, broker.activeCount());
+			assertTrue(s1.isClosed());
+			assertTrue(s2.isClosed());
+		}
+	}
+
 	@Test void a04b_unregisterNullId_throws() {
 		var broker = new BasicMcpSubscriptionBroker(4);
 		assertThrows(IllegalArgumentException.class, () -> broker.unregister(null));
diff --git a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/health/ReadinessState.java b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/health/ReadinessState.java
index 213d06692e..8b46fda68d 100644
--- a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/health/ReadinessState.java
+++ b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/health/ReadinessState.java
@@ -16,6 +16,8 @@
  */
 package org.apache.juneau.rest.server.health;
 
+import java.util.concurrent.*;
+
 import org.apache.juneau.commons.inject.*;
 
 /**
@@ -87,6 +89,7 @@ public final class ReadinessState {
 	}
 
 	private volatile boolean ready = true;
+	private final ConcurrentMap<Object,Runnable> outOfServiceListeners = new ConcurrentHashMap<>();
 
 	/**
 	 * Constructor.
@@ -130,6 +133,40 @@ public final class ReadinessState {
 	 */
 	public ReadinessState markOutOfService() {
 		ready = false;
+		for (var listener : outOfServiceListeners.values()) {
+			try {
+				listener.run();
+			} catch (RuntimeException e) { // NOSONAR - one failing listener must not stop shutdown or the remaining listeners
+				// Deliberately swallowed: shutdown must proceed regardless of a misbehaving listener.
+			}
+		}
+		return this;
+	}
+
+	/**
+	 * Registers a callback to run when this state is {@link #markOutOfService() marked out of service}.
+	 *
+	 * <p>
+	 * Lets components that hold long-lived connections (for example MCP {@code subscriptions/listen} SSE
+	 * streams) end them as soon as shutdown begins, instead of making the server's graceful drain wait out its
+	 * full stop timeout for streams that would otherwise never finish on their own.
+	 *
+	 * <p>
+	 * Registering a second callback under the same <c>key</c> replaces the first, so a component that is
+	 * re-published on every server (re)start does not accumulate duplicate callbacks.  Callbacks run on the
+	 * thread that calls {@link #markOutOfService()}; exceptions they throw are swallowed.
+	 *
+	 * <h5 class='section'>Example:</h5>
+	 * <p class='bjava'>
+	 * 	<jv>state</jv>.onOutOfService(<js>"my-streams"</js>, () -&gt; <jv>streamRegistry</jv>.closeAll());
+	 * </p>
+	 *
+	 * @param key The owner of the callback (typically the registering component).  Must not be <jk>null</jk>.
+	 * @param callback The callback to run.  Must not be <jk>null</jk>.
+	 * @return This object.
+	 */
+	public ReadinessState onOutOfService(Object key, Runnable callback) {
+		outOfServiceListeners.put(key, callback);
 		return this;
 	}
 }
diff --git a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/health/ReadinessStateAware.java b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/health/ReadinessStateAware.java
new file mode 100644
index 0000000000..e68d993b8a
--- /dev/null
+++ b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/health/ReadinessStateAware.java
@@ -0,0 +1,60 @@
+/*
+ * 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.juneau.rest.server.health;
+
+/**
+ * Implemented by servlets that want to be told the embedded server's per-service {@link ReadinessState}, so they
+ * can react when shutdown begins (see {@link ReadinessState#onOutOfService(Object, Runnable)}).
+ *
+ * <p>
+ * The embedded-server lifecycle component (e.g. {@code JettyServerComponent}, {@code TomcatServerComponent})
+ * calls {@link #acceptReadinessState(ReadinessState)} on each auto-discovered servlet that implements this
+ * interface, before the server starts.  A typical use is closing long-lived streaming connections at the start
+ * of a graceful shutdown so the drain does not have to wait out its stop timeout for them.
+ *
+ * <h5 class='section'>Example:</h5>
+ * <p class='bjava'>
+ * 	<ja>@Rest</ja>(path=<js>"/events"</js>)
+ * 	<jk>public class</jk> EventsResource <jk>extends</jk> BasicRestServlet <jk>implements</jk> ReadinessStateAware {
+ *
+ * 		<ja>@Override</ja> <jc>/* ReadinessStateAware *&#47;</jc>
+ * 		<jk>public void</jk> acceptReadinessState(ReadinessState <jv>state</jv>) {
+ * 			<jc>// Stable key: a re-publish on server restart replaces this callback instead of adding another.</jc>
+ * 			<jv>state</jv>.onOutOfService(<js>"my-streams"</js>, <jk>this</jk>::closeStreams);
+ * 		}
+ *
+ * 		<jk>private void</jk> closeStreams() {
+ * 			<jc>// End long-lived SSE/streaming responses so the graceful drain can finish.</jc>
+ * 		}
+ * 	}
+ * </p>
+ *
+ * @since 10.0.0
+ */
+public interface ReadinessStateAware {
+
+	/**
+	 * Receives the lifecycle-owned readiness state.
+	 *
+	 * <p>
+	 * May be called more than once (once per server start); implementations should register callbacks under a
+	 * stable key so repeated calls replace rather than accumulate.
+	 *
+	 * @param state The per-service readiness state.  Never <jk>null</jk>.
+	 */
+	void acceptReadinessState(ReadinessState state);
+}
diff --git a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/httppart/RequestContent.java b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/httppart/RequestContent.java
index 0f20990fa1..0b439cdf58 100644
--- a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/httppart/RequestContent.java
+++ b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/httppart/RequestContent.java
@@ -594,7 +594,7 @@ public class RequestContent {
 
 			try (Closeable in = session.isReaderParser() ? getUnbufferedReader() : getInputStream()) {
 				var o = session.read(in, cm);
-				drain(in);
+				drainQuietly(in);
 				if (nn(schema))
 					schema.validateOutput(o);
 				return o;
@@ -627,23 +627,7 @@ public class RequestContent {
 	// with an immediate EOF.  Draining here (blocking until end-of-stream) keeps the connection reusable.
 	//
 	// Best effort: the stream is still bounded by @Rest(maxInput), and any failure (limit exceeded, client gone)
-	// just falls back to the container's own unconsumed-content handling.  A zero-byte read also stops the loop,
-	// since BoundedServletInputStream returns 0 rather than -1 once its limit is reached.
-	// Q: Can we move this to IOUtils?
-	private static void drain(Closeable in) {
-		try {
-			if (in instanceof Reader r) {
-				var buf = new char[256];
-				while (r.read(buf) > 0) { /* discard */ }
-			} else if (in instanceof InputStream is) {
-				var buf = new byte[256];
-				while (is.read(buf) > 0) { /* discard */ }
-			}
-		} catch (IOException ignored) {
-			// best-effort; the parsed value is already complete
-		}
-	}
-
+	// just falls back to the container's own unconsumed-content handling.
 	/**
 	 * Same as {@link #getReader()}, but doesn't encapsulate the result in a {@link BufferedReader};
 	 *
diff --git a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/util/BoundedServletInputStream.java b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/util/BoundedServletInputStream.java
index de4ffadf69..1ed285f7aa 100644
--- a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/util/BoundedServletInputStream.java
+++ b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/util/BoundedServletInputStream.java
@@ -98,8 +98,12 @@ public class BoundedServletInputStream extends ServletInputStream {
 
 	@Override /* Overridden from InputStream */
 	public int read() throws IOException {
-		decrement();
-		return is.read();
+		if (remain <= 0)
+			return probeAtLimit();
+		var r = is.read();
+		if (r != -1)
+			remain--;
+		return r;
 	}
 
 	@Override /* Overridden from InputStream */
@@ -109,11 +113,13 @@ public class BoundedServletInputStream extends ServletInputStream {
 
 	@Override /* Overridden from InputStream */
 	public int read(byte[] b, int off, int len) throws IOException {
-		long numBytes = Math.min(len, remain);
-		int r = is.read(b, off, (int)numBytes);
-		if (r == -1)
-			return -1;
-		decrement(numBytes);
+		if (len == 0)
+			return 0;
+		if (remain <= 0)
+			return probeAtLimit();
+		int r = is.read(b, off, (int)Math.min(len, remain));
+		if (r > 0)
+			remain -= r;
 		return r;
 	}
 
@@ -136,10 +142,14 @@ public class BoundedServletInputStream extends ServletInputStream {
 		return r;
 	}
 
-	private void decrement() throws IOException {
-		remain--;
-		if (remain < 0)
-			throw ioex("Input limit exceeded.  See @Rest(maxInput).");
+	/**
+	 * Called when the limit has been reached: returns -1 if the underlying stream is also at end-of-stream
+	 * (i.e. the body was exactly the maximum size), otherwise the limit has been exceeded.
+	 */
+	private int probeAtLimit() throws IOException {
+		if (is.read() == -1)
+			return -1;
+		throw ioex("Input limit exceeded.  See @Rest(maxInput).");
 	}
 
 	private void decrement(long count) throws IOException {
diff --git a/juneau-rest/juneau-rest-server/src/test/java/org/apache/juneau/rest/server/health/ReadinessState_Test.java b/juneau-rest/juneau-rest-server/src/test/java/org/apache/juneau/rest/server/health/ReadinessState_Test.java
new file mode 100644
index 0000000000..beea341882
--- /dev/null
+++ b/juneau-rest/juneau-rest-server/src/test/java/org/apache/juneau/rest/server/health/ReadinessState_Test.java
@@ -0,0 +1,55 @@
+/*
+ * 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.juneau.rest.server.health;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.concurrent.atomic.*;
+
+import org.junit.jupiter.api.Test;
+
+class ReadinessState_Test {
+
+	@Test void a01_markOutOfService_runsRegisteredCallbacks() {
+		var state = new ReadinessState();
+		var calls = new AtomicInteger();
+		state.onOutOfService("a", calls::incrementAndGet).onOutOfService("b", calls::incrementAndGet);
+		assertEquals(0, calls.get());
+		state.markOutOfService();
+		assertFalse(state.isReady());
+		assertEquals(2, calls.get());
+	}
+
+	@Test void a02_sameKey_replacesPreviousCallback() {
+		var state = new ReadinessState();
+		var first = new AtomicInteger();
+		var second = new AtomicInteger();
+		state.onOutOfService("k", first::incrementAndGet).onOutOfService("k", second::incrementAndGet);
+		state.markOutOfService();
+		assertEquals(0, first.get());
+		assertEquals(1, second.get());
+	}
+
+	@Test void a03_throwingCallback_doesNotBlockOthersOrShutdown() {
+		var state = new ReadinessState();
+		var ran = new AtomicInteger();
+		state.onOutOfService("bad", () -> { throw new IllegalStateException("boom"); }).onOutOfService("good", ran::incrementAndGet);
+		assertDoesNotThrow(state::markOutOfService);
+		assertFalse(state.isReady());
+		assertEquals(1, ran.get());
+	}
+}
diff --git a/scripts/push.py b/scripts/push.py
index 6bcd045f80..77a411a3c8 100755
--- a/scripts/push.py
+++ b/scripts/push.py
@@ -29,6 +29,11 @@ Usage: python3 push.py "commit message"
        python3 push.py "commit message" --sonarqube
        python3 push.py "commit message" --tracker-audit
        python3 push.py "commit message" --docs-only
+       python3 push.py "commit message" --js-tests      (force the headless-browser JS harness)
+       python3 push.py "commit message" --no-js-tests   (never run it)
+
+JS harness (-Pjs-tests): with neither flag, test.py runs it automatically when a .js/.css/.ftl file
+under a src/ tree differs from origin/master, and skips it with a notice if Node/npm are missing.
 """
 
 # Sound file paths
@@ -620,6 +625,16 @@ def verify_starter_repos(step_num):
     return True
 
 
+def build_test_command(test_script, timing_file, args):
+    """The test.py invocation for the test step; forwards --js-tests/--no-js-tests (auto-detect lives in test.py)."""
+    cmd = [sys.executable, str(test_script), "--full", "--timing-log", str(timing_file)]
+    if getattr(args, "js_tests", False):
+        cmd.append("--js-tests")
+    if getattr(args, "no_js_tests", False):
+        cmd.append("--no-js-tests")
+    return cmd
+
+
 def commit_and_push(
     repo_dir,
     message,
@@ -985,6 +1000,8 @@ Examples:
   python3 push.py "Fixed bug in RestClient" --sonarqube
   python3 push.py "Fixed bug in RestClient" --tracker-audit
   python3 push.py "Updated topic page" --docs-only
+  python3 push.py "Tweaked console JS" --js-tests
+  python3 push.py "Tweaked console JS" --no-js-tests
         """
     )
     
@@ -1042,7 +1059,28 @@ Examples:
         )
     )
     
+    parser.add_argument(
+        "--js-tests",
+        action="store_true",
+        dest="js_tests",
+        help=(
+            "Force the headless-browser JS harness (mvn -Pjs-tests, the CI js-tests job) as part of the "
+            "test step. Fails the push if Node/npm are missing. Default: test.py auto-enables it when a "
+            ".js/.css/.ftl file under a src/ tree changed vs origin/master, and skips with a notice if "
+            "Node/npm are missing."
+        )
+    )
+
+    parser.add_argument(
+        "--no-js-tests",
+        action="store_true",
+        dest="no_js_tests",
+        help="Never run the JS harness, even when JS/CSS/FTL files changed."
+    )
+
     args = parser.parse_args()
+    if args.js_tests and args.no_js_tests:
+        parser.error("--js-tests and --no-js-tests are mutually exclusive")
     
     # Get the Juneau root directory
     script_dir = Path(__file__).parent
@@ -1064,6 +1102,10 @@ Examples:
         print("🔎 SonarQube gate ENABLED (--sonarqube)")
     if args.tracker_audit:
         print("📋 Tracker audit gate ENABLED (--tracker-audit)")
+    if args.js_tests:
+        print("🌐 JS tests FORCED (--js-tests)")
+    if args.no_js_tests:
+        print("🌐 JS tests DISABLED (--no-js-tests)")
     if args.dry_run:
         print("🔍 DRY RUN MODE - No actual changes will be made")
     print("=" * 70)
@@ -1199,7 +1241,7 @@ Examples:
             try:
                 _test_start = time.time()
                 result = subprocess.run(
-                    [sys.executable, str(test_script), "--full", "--timing-log", str(timing_file)],
+                    build_test_command(test_script, timing_file, args),
                     cwd=juneau_root,
                     check=False
                 )
diff --git a/scripts/test.py b/scripts/test.py
index 6615c4b6f8..6071fa65cf 100755
--- a/scripts/test.py
+++ b/scripts/test.py
@@ -25,6 +25,9 @@ Options:
     --no-container           Exclude @Tag("container") tests
     --timing-log <path>      Append per-(module, bucket) timing JSONL records
     --enforce-perf           Hard-fail if wall-clock exceeds perf-baseline.txt ±20% tolerance
+    --js-tests               Also run the headless-browser JS harness (mvn -Pjs-tests, *_BrowserTest in
+                             juneau-rest-server-views).  Fails if Node/npm are missing.
+    --no-js-tests            Never run the JS harness (overrides auto-detect)
     --profile <module>       Run one-shot JFR profile for module tests
     --help, -h               Show this help message
 
@@ -32,6 +35,13 @@ Environment:
     JUNEAU_MVN_WRAPPER       Optional prefix for every mvn command (e.g. a lock script that serializes
                              concurrent runs on one checkout).
 
+JS tests (WORK-J0608):
+    CI's js-tests job is the only thing that normally runs the browser tests (-Pjs-tests).  When neither
+    --js-tests nor --no-js-tests is given, this script enables them automatically if any .js, .css or .ftl
+    file under a src/ tree differs from origin/master (committed, uncommitted or untracked).  If Node/npm
+    are not on the PATH, an auto-enabled run prints a notice and skips; an explicit --js-tests fails instead.
+    The first run downloads Playwright + Chromium (needs network); later runs reuse target/js.
+
 Perf guard (per-module, TODO-160):
     Timing/perf statistics are collected PER MODULE.  write_timing_log() discovers every
     target/surefire-reports/ directory under the reactor (not just juneau-integration-tests's), attributes
@@ -52,6 +62,7 @@ import argparse
 import json
 import os
 import re
+import shutil
 import subprocess
 import sys
 import time
@@ -387,6 +398,71 @@ def test(verbose=False, no_container=False):
 	return run_command(cmd, verbose)
 
 
+JS_TEST_MODULE = "juneau-rest/juneau-rest-server-views"
+JS_SOURCE_SUFFIXES = (".js", ".css", ".ftl")
+
+
+def is_js_source(path):
+	"""True for a .js/.css/.ftl file that lives under a src/ tree (any module)."""
+	p = path.replace("\\", "/")
+	parts = p.split("/")
+	return p.lower().endswith(JS_SOURCE_SUFFIXES) and "src" in parts[:-1]
+
+
+def changed_files_vs_origin(repo_root=None, base="origin/master"):
+	"""Paths changed in HEAD, the working tree or untracked, relative to `base`.  Empty list if git/base unavailable."""
+	repo_root = repo_root or Path(__file__).parent.parent
+	names = []
+	try:
+		for args in (["diff", "--name-only", base], ["ls-files", "--others", "--exclude-standard"]):
+			r = subprocess.run(["git", *args], cwd=str(repo_root), capture_output=True, text=True, check=True)
+			names.extend(line for line in r.stdout.splitlines() if line.strip())
+	except Exception:
+		return []
+	return names
+
+
+def should_run_js_tests(js_flag, no_js_flag, changed_files):
+	"""Resolve (enabled, explicit).  Explicit flags win (--no-js-tests over --js-tests); otherwise auto-detect."""
+	if no_js_flag:
+		return False, False
+	if js_flag:
+		return True, True
+	return any(is_js_source(f) for f in changed_files), False
+
+
+def js_prereq_problem():
+	"""Return a description of the missing browser prerequisite, or None if Node and npm are on the PATH."""
+	missing = [tool for tool in ("node", "npm") if shutil.which(tool) is None]
+	if missing:
+		return f"{' and '.join(missing)} not found on the PATH (the -Pjs-tests profile needs Node and npm)"
+	return None
+
+
+def js_tests(verbose=False):
+	cmd = (f"mvn -Pjs-tests -pl {JS_TEST_MODULE} -am test -Drat.skip=true "
+		"-Dtest='*_BrowserTest' -Dsurefire.failIfNoSpecifiedTests=false")
+	return run_command(cmd, verbose)
+
+
+def maybe_run_js_tests(js_flag, no_js_flag, changed_files, verbose=False, runner=None):
+	"""Run the JS harness if enabled.  Returns 0 on pass/skip, non-zero on failure."""
+	enabled, explicit = should_run_js_tests(js_flag, no_js_flag, changed_files)
+	if not enabled:
+		return 0
+	problem = js_prereq_problem()
+	if problem:
+		if explicit:
+			print(f"\n❌ --js-tests requested but cannot run: {problem}.")
+			return 1
+		print(f"\n⚠️  JS files changed, but skipping JS tests: {problem}. (CI will still run them.)")
+		return 0
+	print("\n🌐 Running JS browser tests (-Pjs-tests)..." + ("" if explicit else " (auto: JS/CSS/FTL files changed)"))
+	code, _ = (runner or js_tests)(verbose)
+	print("\n✅ JS tests passed!" if code == 0 else "\n❌ JS tests failed!")
+	return code
+
+
 def profile(module, verbose=False):
 	ts = datetime.now().strftime("%Y%m%d-%H%M%S")
 	profile_dir = Path("target/profile-results")
@@ -411,6 +487,8 @@ def main():  # NOSONAR python:S3776 -- Cognitive complexity is acceptable for th
 	parser.add_argument("--no-container", action="store_true")
 	parser.add_argument("--timing-log")
 	parser.add_argument("--enforce-perf", action="store_true")
+	parser.add_argument("--js-tests", action="store_true", dest="js_tests")
+	parser.add_argument("--no-js-tests", action="store_true", dest="no_js_tests")
 	parser.add_argument("--profile")
 	parser.add_argument("--help", "-h", action="store_true")
 	args, unknown = parser.parse_known_args()
@@ -434,6 +512,9 @@ def main():  # NOSONAR python:S3776 -- Cognitive complexity is acceptable for th
 	if build_only and test_only:
 		print("Cannot combine --build-only and --test-only")
 		return 1
+	if args.js_tests and args.no_js_tests:
+		print("Cannot combine --js-tests and --no-js-tests")
+		return 1
 
 	exit_code = 0
 	last_test_output = ""
@@ -468,6 +549,10 @@ def main():  # NOSONAR python:S3776 -- Cognitive complexity is acceptable for th
 		perf_exit = run_perf_guard(test_elapsed, baseline_file, args.timing_log, enforce=args.enforce_perf)
 		if perf_exit != 0:
 			return perf_exit
+		js_changed = [] if (args.js_tests or args.no_js_tests) else changed_files_vs_origin()
+		js_exit = maybe_run_js_tests(args.js_tests, args.no_js_tests, js_changed, verbose)
+		if js_exit != 0:
+			return js_exit
 	return exit_code
 
 if __name__ == '__main__':
diff --git a/scripts/tests/README.md b/scripts/tests/README.md
index 5e0362b938..50ed752d1f 100644
--- a/scripts/tests/README.md
+++ b/scripts/tests/README.md
@@ -1,7 +1,7 @@
 # scripts/tests/
 
 Pytest harness for the juneau-only `scripts/` Python that has no other automated coverage:
-`push.py`'s tracker-audit gate, `reset-side-clones.py`, `repin-consumers.py`, and
+`push.py`'s tracker-audit gate, the `--js-tests` flags, `reset-side-clones.py`, `repin-consumers.py`, and
 `wave-survey.py`. Everything here is hermetic -- temp directories, synthetic fixture files, and
 real temporary git repositories built in fixtures -- and never touches the real
 `~/Project Work` trackers, the real `repos.md`, or the real side clones.
@@ -43,6 +43,12 @@ Do **not** `pip install --user pytest` / `pip install pytest` on a Homebrew-mana
   the strongest available proof the gate cannot affect push behavior, including runtime, while
   disabled.
 
+- `test_js_tests_flags.py` -- the `--js-tests` / `--no-js-tests` support in `scripts/test.py` and
+  `scripts/push.py` (WORK-J0608): flag parsing (incl. conflict rejection and push.py forwarding the
+  flags to test.py), auto-detect of changed `.js`/`.css`/`.ftl` files under `src/` (driven by a fake
+  changed-file list, no git), and the Node/npm-missing path (skip with notice when auto-enabled, fail
+  when explicit). Never runs mvn or push.py's real flow.
+
 - `test_reset_side_clones.py` -- `reset-side-clones.py` is not a per-project script. It reads
   the one global `~/Project Work/repos.md` and drives clones by absolute path, so a single copy
   resets the juneau, console, and IRS pools and there is nothing for a second copy to
diff --git a/scripts/tests/test_js_tests_flags.py b/scripts/tests/test_js_tests_flags.py
new file mode 100644
index 0000000000..3b79bf3ed4
--- /dev/null
+++ b/scripts/tests/test_js_tests_flags.py
@@ -0,0 +1,152 @@
+# ***************************************************************************************************************************
+# * 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.
+# ***************************************************************************************************************************
+"""
+Tests for the --js-tests / --no-js-tests support in scripts/test.py and scripts/push.py (WORK-J0608):
+flag parsing, auto-detect of changed JS/CSS/FTL files (driven by a fake changed-file list, no git),
+and the prerequisite-missing path (skip with notice when auto-enabled, fail when explicit).
+Nothing here runs mvn, git against a real remote, or push.py's main().
+"""
+
+from __future__ import annotations
+
+import argparse
+import importlib.util
+import sys
+from pathlib import Path
+
+import pytest
+
+SCRIPTS_DIR = Path(__file__).resolve().parent.parent
+
+
+def _load(name):
+    spec = importlib.util.spec_from_file_location(f"_undertest_{name}", SCRIPTS_DIR / f"{name}.py")
+    module = importlib.util.module_from_spec(spec)
+    spec.loader.exec_module(module)
+    return module
+
+
+@pytest.fixture
+def test_mod():
+    return _load("test")
+
+
+@pytest.fixture
+def push_mod():
+    return _load("push")
+
+
+# ---- auto-detect -----------------------------------------------------------------------------------------------------
+@pytest.mark.parametrize("path,expected", [
+    ("juneau-rest/juneau-rest-server-views/src/main/resources/juneau-views.js", True),
+    ("juneau-rest/x/src/main/resources/style.CSS", True),
+    ("juneau-rest/x/src/main/resources/templates/page.ftl", True),
+    ("a/src/test/js/row-actions.js", True),
+    ("docs/site/app.js", False),            # no src/ segment
+    ("src.js", False),                      # file named src, not a directory
+    ("juneau-core/src/main/java/Foo.java", False),
+    ("a/src/main/resources/readme.md", False),
+])
+def test_is_js_source(test_mod, path, expected):
+    assert test_mod.is_js_source(path) is expected
+
+
+def test_auto_detect_enabled_only_when_js_changed(test_mod):
+    assert test_mod.should_run_js_tests(False, False, ["a/src/x.js"]) == (True, False)
+    assert test_mod.should_run_js_tests(False, False, ["a/src/X.java"]) == (False, False)
+    assert test_mod.should_run_js_tests(False, False, []) == (False, False)
+
+
+def test_explicit_flags_override_detection(test_mod):
+    assert test_mod.should_run_js_tests(True, False, []) == (True, True)
+    assert test_mod.should_run_js_tests(False, True, ["a/src/x.js"]) == (False, False)
+    assert test_mod.should_run_js_tests(True, True, ["a/src/x.js"]) == (False, False)
+
+
+# ---- prereq-missing path ---------------------------------------------------------------------------------------------
+def test_prereq_reports_missing_tools(test_mod, monkeypatch):
+    monkeypatch.setattr(test_mod.shutil, "which", lambda t: None if t == "npm" else "/bin/" + t)
+    assert "npm" in test_mod.js_prereq_problem()
+    monkeypatch.setattr(test_mod.shutil, "which", lambda t: "/bin/" + t)
+    assert test_mod.js_prereq_problem() is None
+
+
+def test_auto_enabled_skips_with_notice_when_prereqs_missing(test_mod, monkeypatch, capsys):
+    monkeypatch.setattr(test_mod, "js_prereq_problem", lambda: "node not found on the PATH")
+    calls = []
+    rc = test_mod.maybe_run_js_tests(False, False, ["a/src/x.js"], runner=lambda v: calls.append(v) or (0, ""))
+    assert rc == 0 and calls == []
+    assert "skipping JS tests" in capsys.readouterr().out
+
+
+def test_explicit_fails_when_prereqs_missing(test_mod, monkeypatch, capsys):
+    monkeypatch.setattr(test_mod, "js_prereq_problem", lambda: "node not found on the PATH")
+    calls = []
+    rc = test_mod.maybe_run_js_tests(True, False, [], runner=lambda v: calls.append(v) or (0, ""))
+    assert rc == 1 and calls == []
+    assert "--js-tests requested but cannot run" in capsys.readouterr().out
+
+
+def test_runs_and_propagates_exit_code(test_mod, monkeypatch):
+    monkeypatch.setattr(test_mod, "js_prereq_problem", lambda: None)
+    assert test_mod.maybe_run_js_tests(True, False, [], runner=lambda v: (0, "")) == 0
+    assert test_mod.maybe_run_js_tests(True, False, [], runner=lambda v: (1, "")) == 1
+
+
+def test_disabled_never_runs_or_checks_prereqs(test_mod, monkeypatch):
+    def boom():
+        raise AssertionError("prereqs must not be checked when disabled")
+    monkeypatch.setattr(test_mod, "js_prereq_problem", boom)
+    assert test_mod.maybe_run_js_tests(False, True, ["a/src/x.js"], runner=boom) == 0
+    assert test_mod.maybe_run_js_tests(False, False, ["a/src/X.java"], runner=boom) == 0
+
+
+def test_changed_files_vs_origin_returns_empty_on_git_failure(test_mod, tmp_path):
+    assert test_mod.changed_files_vs_origin(repo_root=tmp_path) == []  # not a git repo
+
+
+# ---- flag parsing ----------------------------------------------------------------------------------------------------
+def test_test_py_rejects_conflicting_flags(test_mod, monkeypatch, capsys):
+    monkeypatch.setattr(sys, "argv", ["test.py", "--js-tests", "--no-js-tests"])
+    assert test_mod.main() == 1
+    assert "Cannot combine --js-tests and --no-js-tests" in capsys.readouterr().out
+
+
+def test_test_py_help_documents_flags(test_mod):
+    assert "--js-tests" in test_mod.__doc__ and "--no-js-tests" in test_mod.__doc__
+
+
+def test_test_py_accepts_flags(test_mod, monkeypatch):
+    # --build-only with a stubbed build proves parsing accepts the flags and nothing else runs.
+    monkeypatch.setattr(test_mod, "build", lambda v: (0, ""))
+    monkeypatch.setattr(sys, "argv", ["test.py", "--build-only", "--js-tests"])
+    assert test_mod.main() == 0
+
+
+def test_push_py_doc_documents_flags(push_mod):
+    assert "--js-tests" in push_mod.__doc__ and "--no-js-tests" in push_mod.__doc__
+
+
+def test_push_build_test_command_forwards_flags(push_mod):
+    base = push_mod.build_test_command("t.py", "log", argparse.Namespace(js_tests=False, no_js_tests=False))
+    assert "--js-tests" not in base and "--no-js-tests" not in base and base[2] == "--full"
+    assert "--js-tests" in push_mod.build_test_command("t.py", "log", argparse.Namespace(js_tests=True, no_js_tests=False))
+    assert "--no-js-tests" in push_mod.build_test_command("t.py", "log", argparse.Namespace(js_tests=False, no_js_tests=True))
+
+
+def test_push_py_rejects_conflicting_flags(push_mod, monkeypatch, capsys):
+    monkeypatch.setattr(sys, "argv", ["push.py", "msg", "--js-tests", "--no-js-tests"])
+    with pytest.raises(SystemExit) as e:
+        push_mod.main()
+    assert e.value.code == 2
+    assert "mutually exclusive" in capsys.readouterr().err