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<List<Integer>></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>, () -> <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 */</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