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 f5932a70d4 feat(rest-server): async returns + virtual-thread dispatch
(TODO-70); bump Thymeleaf 3.1.5 + OpenTelemetry 1.62.0 (TODO-110, TODO-111)
f5932a70d4 is described below
commit f5932a70d4fb1995a555748bf77e47b33bd2a122
Author: James Bognar <[email protected]>
AuthorDate: Tue May 26 21:10:01 2026 -0400
feat(rest-server): async returns + virtual-thread dispatch (TODO-70); bump
Thymeleaf 3.1.5 + OpenTelemetry 1.62.0 (TODO-110, TODO-111)
- TODO-70: @RestOp CompletableFuture/CompletionStage return support via new
AsyncResponseProcessor + @Rest/@RestOp(asyncTimeoutMillis, default 30s); opt-in
@Rest(virtualThreads=true) per-request virtual-thread dispatch (Java 21+,
graceful warn-and-degrade on Java 17).
- TODO-110: bump org.thymeleaf:thymeleaf 3.1.3.RELEASE -> 3.1.5.RELEASE
(Critical CVE-2026-40477 / 40478 / 41901).
- TODO-111: bump io.opentelemetry:opentelemetry-{api,sdk,sdk-testing}
1.43.0 -> 1.62.0 (Moderate CVE-2026-45292, baggage-propagation unbounded
memory).
---
juneau-rest/juneau-rest-server-otel/pom.xml | 2 +-
.../juneau-rest-server-view-thymeleaf/pom.xml | 2 +-
.../java/org/apache/juneau/rest/RestContext.java | 97 ++++++-
.../java/org/apache/juneau/rest/RestOpContext.java | 56 ++++
.../java/org/apache/juneau/rest/RestOpInvoker.java | 99 ++++++-
.../java/org/apache/juneau/rest/RestOpSession.java | 6 +
.../apache/juneau/rest/RestServerConstants.java | 14 +
.../java/org/apache/juneau/rest/RestSession.java | 3 +-
.../org/apache/juneau/rest/annotation/Rest.java | 76 +++++
.../juneau/rest/annotation/RestAnnotation.java | 38 +++
.../org/apache/juneau/rest/annotation/RestOp.java | 49 ++++
.../juneau/rest/annotation/RestOpAnnotation.java | 38 +++
.../apache/juneau/rest/config/DefaultConfig.java | 1 +
.../rest/processor/AsyncResponseProcessor.java | 310 +++++++++++++++++++++
juneau-utest/pom.xml | 8 +-
.../juneau/rest/VirtualThreadDispatch_Test.java | 228 +++++++++++++++
.../processor/AsyncResponseProcessor_Test.java | 230 +++++++++++++++
17 files changed, 1245 insertions(+), 12 deletions(-)
diff --git a/juneau-rest/juneau-rest-server-otel/pom.xml
b/juneau-rest/juneau-rest-server-otel/pom.xml
index a76b6fd37d..8ff272889f 100644
--- a/juneau-rest/juneau-rest-server-otel/pom.xml
+++ b/juneau-rest/juneau-rest-server-otel/pom.xml
@@ -32,7 +32,7 @@
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
- <opentelemetry.version>1.43.0</opentelemetry.version>
+ <opentelemetry.version>1.62.0</opentelemetry.version>
</properties>
<dependencies>
diff --git a/juneau-rest/juneau-rest-server-view-thymeleaf/pom.xml
b/juneau-rest/juneau-rest-server-view-thymeleaf/pom.xml
index 7deee797c7..de520db623 100644
--- a/juneau-rest/juneau-rest-server-view-thymeleaf/pom.xml
+++ b/juneau-rest/juneau-rest-server-view-thymeleaf/pom.xml
@@ -32,7 +32,7 @@
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
- <thymeleaf.version>3.1.3.RELEASE</thymeleaf.version>
+ <thymeleaf.version>3.1.5.RELEASE</thymeleaf.version>
</properties>
<!--
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestContext.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestContext.java
index 9c48a3dc93..6105d8a180 100644
---
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestContext.java
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestContext.java
@@ -2165,6 +2165,69 @@ public class RestContext extends Context {
private final Memoizer<Boolean> problemDetails = memoizer(() ->
mergeReplacedBooleanAttribute(PROPERTY_problemDetails,
env("RestContext.problemDetails", false)));
+ /**
+ * Whether the resource opts into per-request virtual-thread dispatch
on Java 21+; resolved from
+ * {@code @Rest(virtualThreads)} (TODO-70).
+ *
+ * <p>
+ * Detection happens at context-init via {@link
#mergeReplacedBooleanAttribute(String, boolean)}; on JVMs older
+ * than Java 21 the flag is logged once and ignored — see {@link
#virtualThreadExecutor}.
+ */
+ private final Memoizer<Boolean> virtualThreadsEnabled = memoizer(() ->
+ mergeReplacedBooleanAttribute(PROPERTY_virtualThreads,
env("RestContext.virtualThreads", false)));
+
+ /**
+ * Configurable async-response timeout (milliseconds) applied by {@code
AsyncResponseProcessor} to
+ * {@link CompletableFuture}-returning handlers; resolved from {@code
@Rest(asyncTimeoutMillis)} (TODO-70).
+ *
+ * <p>
+ * {@code 0} disables the timeout. The default 30-second fallback is
applied by {@code AsyncResponseProcessor}
+ * itself when neither the resource nor the operation declares a value.
+ */
+ private final Memoizer<Long> asyncTimeoutMillis = memoizer(() -> {
+ var s =
mergeReplacedStringAttribute(PROPERTY_asyncTimeoutMillis, null);
+ if (s == null || s.isEmpty())
+ return -1L;
+ try {
+ return Long.parseLong(s.trim());
+ } catch (NumberFormatException nfe) {
+ ASYNC_LOG.log(Level.WARNING, () -> "Invalid
@Rest(asyncTimeoutMillis) value '" + s + "' — falling back to default.");
+ return -1L;
+ }
+ });
+
+ /**
+ * Lazily-instantiated virtual-thread executor used by {@link
RestOpInvoker} when
+ * {@code @Rest(virtualThreads=true)} is set on this resource and the
runtime is Java 21+.
+ *
+ * <p>
+ * On Java 17/18/19/20 the supplier returns {@code null} and emits a
one-shot {@code WARNING} log so the
+ * resource degrades gracefully to caller-thread dispatch (TODO-70
graceful-degradation contract).
+ */
+ private final Memoizer<Executor> virtualThreadExecutor = memoizer(() ->
{
+ // NOTE: Intentionally not gated on resource-level {@code
virtualThreadsEnabled.get()} — per-op
+ // {@code @RestOp(virtualThreads="true")} can opt in even when
the enclosing {@code @Rest} doesn't.
+ // Callers ({@link RestOpInvoker#invokeOp}) only reach this
method when the effective op-level flag is true,
+ // so the warning emitted on Java <21 is appropriate at
first call regardless of where the flag is set.
+ if (Runtime.version().feature() < 21) {
+ ASYNC_LOG.log(Level.WARNING, () -> "virtualThreads=true
configured on " + getResourceClass().getName()
+ + " but runtime is Java " +
Runtime.version().feature() + " — virtual-thread dispatch requires Java 21+. "
+ + "Falling back to caller-thread dispatch.");
+ return null;
+ }
+ try {
+ var m =
Executors.class.getMethod("newVirtualThreadPerTaskExecutor");
+ return (Executor) m.invoke(null);
+ } catch (ReflectiveOperationException e) {
+ ASYNC_LOG.log(Level.WARNING, e, () -> "Reflective
creation of virtual-thread executor failed on "
+ + getResourceClass().getName() + " — falling
back to caller-thread dispatch.");
+ return null;
+ }
+ });
+
+ /** Logger for async / virtual-thread setup events (TODO-70). Used at
memoizer init before {@link #getLogger()} may be wired up. */
+ private static final Logger ASYNC_LOG =
Logger.getLogger(RestContext.class.getName() + ".async");
+
/**
* Whether framework memoizers and operation/child contexts should be
force-initialized during constructor execution;
* resolved from {@code @Rest(eagerInit)}.
@@ -3315,6 +3378,36 @@ public class RestContext extends Context {
*/
public boolean isProblemDetails() { return problemDetails.get(); }
+ /**
+ * Returns whether the resource opted into per-request virtual-thread
dispatch (Java 21+) via
+ * {@code @Rest(virtualThreads=true)}.
+ *
+ * <p>
+ * The flag is honored only when {@link #getVirtualThreadExecutor()}
returns a non-{@code null} executor; on
+ * runtimes older than Java 21 the executor is {@code null} and the
flag is logged once at context init.
+ *
+ * @return <jk>true</jk> if virtual-thread dispatch is configured on
this resource.
+ */
+ public boolean isVirtualThreadsEnabled() { return
virtualThreadsEnabled.get(); }
+
+ /**
+ * Returns the lazily-instantiated virtual-thread executor for this
resource, or {@code null} when
+ * {@code @Rest(virtualThreads=true)} is unset, the runtime is older
than Java 21, or executor construction
+ * failed (in which case a {@code WARNING} was logged at context init).
+ *
+ * @return The executor, or {@code null} if virtual-thread dispatch is
not active for this resource.
+ */
+ public Executor getVirtualThreadExecutor() { return
virtualThreadExecutor.get(); }
+
+ /**
+ * Returns the configured async-response timeout (milliseconds) for
this resource. {@code -1} indicates that no
+ * value was supplied at the resource level — the per-op value (or
{@code AsyncResponseProcessor}'s 30-second
+ * default) applies in that case.
+ *
+ * @return The async timeout in milliseconds, or {@code -1} when unset.
+ */
+ public long getAsyncTimeoutMillis() { return asyncTimeoutMillis.get(); }
+
/**
* Returns whether framework beans and operation/child contexts are
eagerly initialized at construction time.
*
@@ -3457,7 +3550,7 @@ public class RestContext extends Context {
* @param t The thrown object.
* @return The converted thrown object.
*/
- protected Throwable convertThrowable(Throwable t) {
+ public Throwable convertThrowable(Throwable t) {
if (t instanceof InvocationTargetException t2)
t = t2.getTargetException();
@@ -3855,7 +3948,7 @@ public class RestContext extends Context {
@SuppressWarnings({
"java:S127" // Loop counter i resets to -1 on RESTART
})
- protected void processResponse(RestOpSession opSession) throws
IOException, BasicHttpException, NotImplemented {
+ public void processResponse(RestOpSession opSession) throws
IOException, BasicHttpException, NotImplemented {
// Loop until we find the correct processor for the POJO.
int loops = 5;
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestOpContext.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestOpContext.java
index d3116cc08d..96e59eba17 100644
---
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestOpContext.java
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestOpContext.java
@@ -640,6 +640,41 @@ public class RestOpContext extends Context implements
Comparable<RestOpContext>
return false;
});
+ /**
+ * Whether this operation opts into per-request virtual-thread dispatch
on Java 21+.
+ *
+ * <p>
+ * Tri-state semantics on {@code @RestOp}-group annotations: {@code
"true"} enables, {@code "false"} disables
+ * (overrides an opted-in resource), and {@code ""} (default) inherits
from {@code @Rest(virtualThreads)}. Honored
+ * only when the resource-level {@link
RestContext#getVirtualThreadExecutor()} is non-{@code null} (Java 21+).
+ */
+ private final Memoizer<Boolean> virtualThreadsEnabled = memoizer(() -> {
+ var v = findOpString(PROPERTY_virtualThreads);
+ if (v.isPresent())
+ return Boolean.parseBoolean(v.get());
+ if (isInherited(PROPERTY_virtualThreads))
+ return restContext().isVirtualThreadsEnabled();
+ return false;
+ });
+
+ /**
+ * Configurable async-response timeout (milliseconds) for this
operation; {@code -1} when neither the op
+ * annotation nor the resource declares a value (so {@code
AsyncResponseProcessor}'s 30-second default applies).
+ */
+ private final Memoizer<Long> asyncTimeoutMillis = memoizer(() -> {
+ var v = findOpString(PROPERTY_asyncTimeoutMillis);
+ if (v.isPresent()) {
+ try {
+ return Long.parseLong(v.get().trim());
+ } catch (NumberFormatException nfe) {
+ return -1L;
+ }
+ }
+ if (isInherited(PROPERTY_asyncTimeoutMillis))
+ return restContext().getAsyncTimeoutMillis();
+ return -1L;
+ });
+
/** Aggregated {@code noInherit} keys from all RestOp-group annotations
on this operation. */
private final Memoizer<SortedSet<String>> noInheritOp = memoizer(() -> {
var l = getRestOpAnnotations().stream()
@@ -1410,6 +1445,27 @@ public class RestOpContext extends Context implements
Comparable<RestOpContext>
*/
public boolean isProblemDetails() { return problemDetails.get(); }
+ /**
+ * Returns whether this operation opts into per-request virtual-thread
dispatch on Java 21+.
+ *
+ * <p>
+ * The op-level {@code @RestOp(virtualThreads)} value (when non-blank)
wins; otherwise the value is inherited
+ * from {@link RestContext#isVirtualThreadsEnabled()} (resource-level
{@code @Rest(virtualThreads)}). Honored
+ * only when the resource-level {@link
RestContext#getVirtualThreadExecutor()} is non-{@code null} (Java 21+).
+ *
+ * @return <jk>true</jk> if virtual-thread dispatch is configured on
this operation.
+ */
+ public boolean isVirtualThreadsEnabled() { return
virtualThreadsEnabled.get(); }
+
+ /**
+ * Returns the configured async-response timeout (milliseconds) for
this operation, or {@code -1} when no
+ * value was supplied at the op or resource level — in which case the
default 30-second fallback applies in
+ * {@link org.apache.juneau.rest.processor.AsyncResponseProcessor}.
+ *
+ * @return The async timeout in milliseconds, or {@code -1} when unset.
+ */
+ public long getAsyncTimeoutMillis() { return asyncTimeoutMillis.get(); }
+
/**
* Returns the parsers to use for this method.
*
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestOpInvoker.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestOpInvoker.java
index b4374bf310..c86deeecc2 100644
---
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestOpInvoker.java
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestOpInvoker.java
@@ -20,6 +20,7 @@ import static org.apache.juneau.commons.utils.Utils.*;
import java.lang.reflect.*;
import java.time.*;
+import java.util.concurrent.*;
import java.util.function.*;
import org.apache.juneau.commons.reflect.*;
@@ -87,13 +88,62 @@ public class RestOpInvoker extends MethodInvoker {
* {@link RestContext#postCall(RestOpSession)}) continue to use {@link
#invoke(RestOpSession)} so the
* observability boundary stays anchored on the user-facing handler.
*
+ * <h5 class='section'>Virtual-thread dispatch (TODO-70)</h5>
+ * <p>
+ * When the operation opts into virtual threads via {@code
@Rest(virtualThreads=true)} or
+ * {@code @RestOp(virtualThreads=true)} and the runtime is Java 21+,
the entire body of this method
+ * (parameter resolution, observability scope opening, handler
invocation, observability close) is
+ * submitted to the resource's virtual-thread executor and the request
thread blocks on completion.
+ * That keeps observability scoping correct (open and close run on the
same thread) while letting
+ * blocking I/O inside the handler park a virtual thread instead of the
carrier.
+ *
+ * <h5 class='section'>{@link CompletableFuture} returns (TODO-70)</h5>
+ * <p>
+ * When the handler returns a {@link CompletionStage}, the
observability close is deferred to the
+ * future's {@code whenComplete} callback so metrics and traces capture
the actual completion time
+ * and outcome — not the synchronous "future returned" moment.
+ *
* @param opSession The REST call.
* @throws Exception If an error occurred during either parameter
resolution or method invocation.
*/
public void invokeOp(RestOpSession opSession) throws Exception {
- invoke(opSession, true);
+ var vtExec = opSession.getContext().isVirtualThreadsEnabled()
+ ? opSession.getRestContext().getVirtualThreadExecutor()
+ : null;
+
+ if (vtExec == null) {
+ invoke(opSession, true);
+ return;
+ }
+
+ var f = new CompletableFuture<Void>();
+ vtExec.execute(() -> {
+ try {
+ invoke(opSession, true);
+ f.complete(null);
+ } catch (Throwable t) {
+ f.completeExceptionally(t);
+ }
+ });
+
+ try {
+ f.get();
+ } catch (ExecutionException ee) {
+ Throwable cause = ee.getCause() == null ? ee :
ee.getCause();
+ if (cause instanceof Exception ex)
+ throw ex;
+ if (cause instanceof Error err)
+ throw err;
+ throw new InternalServerError(cause);
+ } catch (InterruptedException ie) {
+ Thread.currentThread().interrupt();
+ throw new InternalServerError(ie);
+ }
}
+ @SuppressWarnings({
+ "java:S3776" // Cognitive complexity acceptable for the
dispatch hot path.
+ })
private void invoke(RestOpSession opSession, boolean observable) throws
Exception {
var args = new Object[opArgs.length];
for (var i = 0; i < opArgs.length; i++) {
@@ -114,6 +164,7 @@ public class RestOpInvoker extends MethodInvoker {
Scope tracerScope = NoOpTracerHook.NoOpScope.INSTANCE;
long startNanos = 0L;
Throwable observed = null;
+ boolean observabilityDeferred = false;
if (observable) {
var bs = opSession.getRestContext().getBeanStore();
recorder =
bs.getBean(MetricsRecorder.class).orElse(NoOpMetricsRecorder.INSTANCE);
@@ -139,6 +190,11 @@ public class RestOpInvoker extends MethodInvoker {
if (! inner().hasReturnType(Void.TYPE) && (nn(output)
|| ! res.getOutputStreamCalled()))
res.setContent(output);
+ if (observable && output instanceof CompletionStage<?>
stage) {
+ observabilityDeferred = true;
+ deferObservability(stage, recorder,
tracerScope, startNanos, opSession);
+ }
+
} catch (IllegalAccessException | IllegalArgumentException e) {
observed = e;
throw new InternalServerError(e, "Error occurred
invoking method ''{0}''.", inner().getNameFull());
@@ -148,7 +204,7 @@ public class RestOpInvoker extends MethodInvoker {
res.setStatus(500); // May be overridden later.
res.setContent(opSession.getRestContext().convertThrowable(e2));
} finally {
- if (observable) {
+ if (observable && ! observabilityDeferred) {
int status = res.getStatus();
if (status == 0)
status = (observed == null) ? 200 : 500;
@@ -169,6 +225,43 @@ public class RestOpInvoker extends MethodInvoker {
}
}
+ private void deferObservability(CompletionStage<?> stage,
MetricsRecorder recorder, Scope tracerScope,
+ long startNanos, RestOpSession opSession) {
+ var fullName = getFullName();
+ var httpMethod = opSession.getRequest().getMethod();
+ var pathTemplate = resolveUriTemplate(opSession);
+ stage.whenComplete((value, error) -> {
+ Throwable err = unwrapCompletionError(error);
+ int status = deriveStatus(err);
+ try {
+ tracerScope.setStatusCode(status);
+ if (err != null)
+ tracerScope.setError(err);
+ } finally {
+ try {
+ tracerScope.close();
+ } finally {
+ var elapsed =
Duration.ofNanos(System.nanoTime() - startNanos);
+ recorder.record(fullName, httpMethod,
pathTemplate, status, elapsed, err);
+ }
+ }
+ });
+ }
+
+ private static Throwable unwrapCompletionError(Throwable t) {
+ if (t instanceof CompletionException && t.getCause() != null)
+ return t.getCause();
+ return t;
+ }
+
+ private static int deriveStatus(Throwable err) {
+ if (err == null)
+ return 200;
+ if (err instanceof BasicHttpException bhe)
+ return bhe.getStatusCode();
+ return 500;
+ }
+
private static String resolveUriTemplate(RestOpSession opSession) {
try {
var pp = opSession.getContext().getPathPattern();
@@ -178,4 +271,4 @@ public class RestOpInvoker extends MethodInvoker {
return "";
}
}
-}
\ No newline at end of file
+}
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestOpSession.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestOpSession.java
index 318ae4c15a..21568713fd 100644
---
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestOpSession.java
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestOpSession.java
@@ -26,6 +26,7 @@ import org.apache.juneau.commons.collections.FluentMap;
import org.apache.juneau.commons.inject.*;
import org.apache.juneau.http.response.*;
import org.apache.juneau.rest.logger.*;
+import org.apache.juneau.rest.processor.*;
/**
* A session for a single HTTP request.
@@ -147,6 +148,11 @@ public class RestOpSession extends ContextSession {
* @return This object.
*/
public RestOpSession finish() {
+ // TODO-70: when AsyncResponseProcessor has handed off to a
real AsyncContext, the response will be
+ // committed by AsyncContext.complete() inside the future's
whenComplete callback — synchronously
+ // flushing here would commit the response prematurely.
+ if (AsyncResponseProcessor.isAsyncDispatchOwned(this))
+ return this;
try {
res.flushBuffer();
req.close();
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestServerConstants.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestServerConstants.java
index 9e379da7f2..221cb485ff 100644
---
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestServerConstants.java
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestServerConstants.java
@@ -69,6 +69,20 @@ public final class RestServerConstants {
/** The {@code "eagerInit"} annotation attribute name — used in {@code
noInherit} matching. */
public static final String PROPERTY_eagerInit = "eagerInit";
+ /**
+ * The {@code "virtualThreads"} annotation attribute name — used in
{@code noInherit} matching to opt the resource
+ * (or one of its {@code @RestOp}-annotated methods) into per-request
virtual-thread dispatch on Java 21+. On
+ * runtimes older than Java 21 the flag is logged once and ignored.
+ */
+ public static final String PROPERTY_virtualThreads = "virtualThreads";
+
+ /**
+ * The {@code "asyncTimeoutMillis"} annotation attribute name — used in
{@code noInherit} matching to override the
+ * default 30-second timeout applied by {@link
org.apache.juneau.rest.processor.AsyncResponseProcessor} to
+ * {@link java.util.concurrent.CompletableFuture}-returning handlers.
{@code "0"} disables the timeout entirely.
+ */
+ public static final String PROPERTY_asyncTimeoutMillis =
"asyncTimeoutMillis";
+
/** The {@code "clientVersionHeader"} annotation attribute name — used
in {@code noInherit} matching. */
public static final String PROPERTY_clientVersionHeader =
"clientVersionHeader";
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestSession.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestSession.java
index 41866f1cf9..3a7ba3f7df 100644
---
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestSession.java
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestSession.java
@@ -288,7 +288,8 @@ public class RestSession extends ContextSession {
req.setAttribute("ExecTime", System.currentTimeMillis()
- startTime);
if (nn(opSession))
opSession.finish();
- else {
+ else if (!
org.apache.juneau.rest.processor.AsyncResponseProcessor.isAsyncDispatchOwned(req))
{
+ // TODO-70: skip flush when AsyncContext has
been started — see AsyncResponseProcessor.
res.flushBuffer();
}
} catch (Exception e) {
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/Rest.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/Rest.java
index aec2046e0d..f6f7d1521c 100644
---
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/Rest.java
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/Rest.java
@@ -693,6 +693,82 @@ public @interface Rest {
*/
String problemDetails() default "";
+ /**
+ * Opt this resource into per-request virtual-thread dispatch (Java
21+).
+ *
+ * <p>
+ * When enabled, every {@code @RestOp}-annotated handler invocation on
this resource is submitted to a
+ * {@link
java.util.concurrent.Executors#newVirtualThreadPerTaskExecutor()
virtual-thread-per-task executor}
+ * lazily built by the {@link org.apache.juneau.rest.RestContext}. The
platform request thread blocks on
+ * the virtual thread's completion (so the handler's return value,
exceptions, and observability hooks are
+ * preserved verbatim), but blocking I/O inside the handler now parks a
virtual thread instead of the
+ * carrier — i.e. the carrier thread is freed to service other
concurrent requests while the handler is
+ * parked on socket / file / lock waits. Combined with {@link
java.util.concurrent.CompletableFuture}
+ * return types ({@code @RestGet} / {@code @RestPost} returning {@code
CompletableFuture<T>}) this is the
+ * high-throughput pattern.
+ *
+ * <p>
+ * <b>Graceful degradation on Java 17/18/19/20:</b> the flag is
detected during {@code RestContext}
+ * initialization. If the runtime is older than Java 21, a one-shot
{@code WARNING} is logged and the
+ * resource falls back to the standard caller-thread dispatch path — no
runtime error.
+ *
+ * <p>
+ * Per-{@code @RestOp} overrides are available via {@link
RestOp#virtualThreads()}; the op-level setting
+ * takes precedence over the resource-level setting.
+ *
+ * <ul class='values'>
+ * <li><js>"true"</js> — enable virtual-thread dispatch on
Java 21+ (silently disabled on older JVMs).
+ * <li><js>"false"</js> — explicitly disable.
+ * <li><js>""</js> (default) — inherit from the
next-most-derived {@code @Rest} in the resource-class
+ * hierarchy.
+ * </ul>
+ *
+ * <h5 class='section'>Notes:</h5><ul>
+ * <li class='note'>
+ * Supports <a class="doclink"
href="https://juneau.apache.org/docs/topics/RestServerSvlVariables">SVL
Variables</a>
+ * (e.g. <js>"$E{ENABLE_VIRTUAL_THREADS,false}"</js>).
+ * <li class='note'>
+ * Synchronized blocks and JNI calls in handler code
<i>pin</i> a virtual thread to its carrier thread
+ * — prefer {@link
java.util.concurrent.locks.ReentrantLock} over {@code synchronized} in handlers
run
+ * under this flag.
+ * </ul>
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * <li class='ja'>{@link RestOp#virtualThreads()}
+ * </ul>
+ *
+ * @return The annotation value.
+ */
+ String virtualThreads() default "";
+
+ /**
+ * Configurable timeout (milliseconds) applied to {@link
java.util.concurrent.CompletableFuture}-returning
+ * handlers by {@link
org.apache.juneau.rest.processor.AsyncResponseProcessor}. Default is 30,000 ms.
+ *
+ * <p>
+ * On timeout, the future is cancelled with {@code
mayInterruptIfRunning=true} and the response is
+ * committed as {@code 504 Gateway Timeout}. Set to {@code "0"} to
disable the timeout entirely.
+ *
+ * <p>
+ * Per-{@code @RestOp} overrides are available via {@link
RestOp#asyncTimeoutMillis()}; the op-level
+ * setting takes precedence over the resource-level setting.
+ *
+ * <h5 class='section'>Notes:</h5><ul>
+ * <li class='note'>
+ * Supports <a class="doclink"
href="https://juneau.apache.org/docs/topics/RestServerSvlVariables">SVL
Variables</a>
+ * (e.g. <js>"$E{ASYNC_TIMEOUT_MS,30000}"</js>).
+ * </ul>
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * <li class='jc'>{@link
org.apache.juneau.rest.processor.AsyncResponseProcessor}
+ * <li class='ja'>{@link RestOp#asyncTimeoutMillis()}
+ * </ul>
+ *
+ * @return The annotation value.
+ * @since 9.5.0
+ */
+ String asyncTimeoutMillis() default "";
+
/**
* Specifies the compression encoders for this resource.
*
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestAnnotation.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestAnnotation.java
index 0ae4bc41b4..deba03a0c8 100644
---
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestAnnotation.java
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestAnnotation.java
@@ -92,6 +92,8 @@ public class RestAnnotation {
private String path = "";
private String[] paths = {};
private String problemDetails = "";
+ private String virtualThreads = "";
+ private String asyncTimeoutMillis = "";
private String renderResponseStackTraces = "";
private String roleGuard = "";
private String rolesDeclared = "";
@@ -518,6 +520,28 @@ public class RestAnnotation {
return this;
}
+ /**
+ * Sets the {@link Rest#virtualThreads()} property on this
annotation.
+ *
+ * @param value The new value for this property.
+ * @return This object.
+ */
+ public Builder virtualThreads(String value) {
+ virtualThreads = value;
+ return this;
+ }
+
+ /**
+ * Sets the {@link Rest#asyncTimeoutMillis()} property on this
annotation.
+ *
+ * @param value The new value for this property.
+ * @return This object.
+ */
+ public Builder asyncTimeoutMillis(String value) {
+ asyncTimeoutMillis = value;
+ return this;
+ }
+
/**
* Sets the {@link Rest#renderResponseStackTraces()} property
on this annotation.
*
@@ -737,6 +761,8 @@ public class RestAnnotation {
private final String path;
private final String[] paths;
private final String problemDetails;
+ private final String virtualThreads;
+ private final String asyncTimeoutMillis;
private final String renderResponseStackTraces;
private final String roleGuard;
private final String rolesDeclared;
@@ -794,6 +820,8 @@ public class RestAnnotation {
path = b.path;
paths = copyOf(b.paths);
problemDetails = b.problemDetails;
+ virtualThreads = b.virtualThreads;
+ asyncTimeoutMillis = b.asyncTimeoutMillis;
produces = copyOf(b.produces);
renderResponseStackTraces = b.renderResponseStackTraces;
responseProcessors = copyOf(b.responseProcessors);
@@ -1002,6 +1030,16 @@ public class RestAnnotation {
return problemDetails;
}
+ @Override /* Overridden from Rest */
+ public String virtualThreads() {
+ return virtualThreads;
+ }
+
+ @Override /* Overridden from Rest */
+ public String asyncTimeoutMillis() {
+ return asyncTimeoutMillis;
+ }
+
@Override /* Overridden from Rest */
public String renderResponseStackTraces() {
return renderResponseStackTraces;
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestOp.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestOp.java
index 3aa99f1f88..3d3a394c00 100644
---
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestOp.java
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestOp.java
@@ -691,6 +691,55 @@ public @interface RestOp {
*/
String problemDetails() default "";
+ /**
+ * Per-operation override for {@link Rest#virtualThreads()
@Rest(virtualThreads)}.
+ *
+ * <p>
+ * When set, this value takes precedence over the resource-level
setting for this operation. Tri-state
+ * semantics:
+ *
+ * <ul class='values'>
+ * <li><js>"true"</js> — enable virtual-thread dispatch for
this operation (Java 21+ only;
+ * gracefully ignored on older JVMs).
+ * <li><js>"false"</js> — disable virtual-thread dispatch
for this operation (overrides an
+ * opted-in resource).
+ * <li><js>""</js> (default) — inherit from {@link
Rest#virtualThreads()}.
+ * </ul>
+ *
+ * <h5 class='section'>Notes:</h5><ul>
+ * <li class='note'>
+ * Supports <a class="doclink"
href="https://juneau.apache.org/docs/topics/RestServerSvlVariables">SVL
Variables</a>
+ * (e.g. <js>"$E{ENABLE_VT,false}"</js>).
+ * </ul>
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * <li class='ja'>{@link Rest#virtualThreads()}
+ * </ul>
+ *
+ * @return The annotation value.
+ */
+ String virtualThreads() default "";
+
+ /**
+ * Per-operation override for {@link Rest#asyncTimeoutMillis()
@Rest(asyncTimeoutMillis)}.
+ *
+ * <p>
+ * Configurable timeout (milliseconds) applied to {@link
java.util.concurrent.CompletableFuture}-returning
+ * handlers. Default is 30,000 ms inherited from the resource. Set to
{@code "0"} to disable the timeout.
+ *
+ * <h5 class='section'>Notes:</h5><ul>
+ * <li class='note'>
+ * Supports <a class="doclink"
href="https://juneau.apache.org/docs/topics/RestServerSvlVariables">SVL
Variables</a>.
+ * </ul>
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * <li class='ja'>{@link Rest#asyncTimeoutMillis()}
+ * </ul>
+ *
+ * @return The annotation value.
+ */
+ String asyncTimeoutMillis() default "";
+
/**
* Supported accept media types.
*
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestOpAnnotation.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestOpAnnotation.java
index d74f87abd9..1477b02416 100644
---
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestOpAnnotation.java
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestOpAnnotation.java
@@ -72,6 +72,8 @@ public class RestOpAnnotation {
private String maxInput = "";
private String method = "";
private String problemDetails = "";
+ private String virtualThreads = "";
+ private String asyncTimeoutMillis = "";
private String rolesDeclared = "";
private String roleGuard = "";
private String summary = "";
@@ -350,6 +352,28 @@ public class RestOpAnnotation {
return this;
}
+ /**
+ * Sets the {@link RestOp#virtualThreads()} property on this
annotation.
+ *
+ * @param value The new value for this property.
+ * @return This object.
+ */
+ public Builder virtualThreads(String value) {
+ virtualThreads = value;
+ return this;
+ }
+
+ /**
+ * Sets the {@link RestOp#asyncTimeoutMillis()} property on
this annotation.
+ *
+ * @param value The new value for this property.
+ * @return This object.
+ */
+ public Builder asyncTimeoutMillis(String value) {
+ asyncTimeoutMillis = value;
+ return this;
+ }
+
/**
* Sets the {@link RestOp#produces()} property on this
annotation.
*
@@ -489,6 +513,8 @@ public class RestOpAnnotation {
private final String maxInput;
private final String method;
private final String problemDetails;
+ private final String virtualThreads;
+ private final String asyncTimeoutMillis;
private final String rolesDeclared;
private final String roleGuard;
private final String summary;
@@ -528,6 +554,8 @@ public class RestOpAnnotation {
parsers = copyOf(b.parsers);
path = copyOf(b.path);
problemDetails = b.problemDetails;
+ virtualThreads = b.virtualThreads;
+ asyncTimeoutMillis = b.asyncTimeoutMillis;
produces = copyOf(b.produces);
allowedSerializerOptions =
copyOf(b.allowedSerializerOptions);
allowedParserOptions = copyOf(b.allowedParserOptions);
@@ -640,6 +668,16 @@ public class RestOpAnnotation {
return problemDetails;
}
+ @Override /* Overridden from RestOp */
+ public String virtualThreads() {
+ return virtualThreads;
+ }
+
+ @Override /* Overridden from RestOp */
+ public String asyncTimeoutMillis() {
+ return asyncTimeoutMillis;
+ }
+
@Override /* Overridden from RestOp */
public String[] produces() {
return produces;
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/config/DefaultConfig.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/config/DefaultConfig.java
index 7e29c7b07a..0a74b16995 100644
---
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/config/DefaultConfig.java
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/config/DefaultConfig.java
@@ -50,6 +50,7 @@ import org.apache.juneau.serializer.annotation.*;
partParser=OpenApiParser.class,
partSerializer=OpenApiSerializer.class,
responseProcessors={
+ AsyncResponseProcessor.class,
ReaderProcessor.class,
InputStreamProcessor.class,
ThrowableProcessor.class,
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/processor/AsyncResponseProcessor.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/processor/AsyncResponseProcessor.java
new file mode 100644
index 0000000000..8f4f6f56b6
--- /dev/null
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/processor/AsyncResponseProcessor.java
@@ -0,0 +1,310 @@
+/*
+ * 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.processor;
+
+import static jakarta.servlet.http.HttpServletResponse.*;
+
+import java.io.*;
+import java.util.concurrent.*;
+import java.util.concurrent.atomic.*;
+import java.util.logging.*;
+
+import jakarta.servlet.*;
+import jakarta.servlet.http.*;
+
+import org.apache.juneau.http.response.*;
+import org.apache.juneau.rest.*;
+
+/**
+ * Response processor that unwraps {@link CompletableFuture} / {@link
CompletionStage} return values from
+ * {@code @RestOp}-annotated methods and bridges them to the servlet
container's
+ * {@link AsyncContext asynchronous} request lifecycle.
+ *
+ * <p>
+ * When a handler returns a future, this processor is the FIRST processor in
the default chain
+ * (see {@link org.apache.juneau.rest.config.DefaultConfig}). It detects the
{@link CompletionStage}
+ * content, calls {@link HttpServletRequest#startAsync()} on the underlying
servlet request, and
+ * registers a {@link
CompletionStage#whenComplete(java.util.function.BiConsumer) whenComplete}
+ * callback that:
+ *
+ * <ul>
+ * <li>On success — installs the unwrapped value via {@link
RestResponse#setContent(Object)}, re-runs
+ * {@link RestContext#processResponse(RestOpSession)} (this
processor sees a non-future and falls
+ * through to {@code SerializedPojoProcessor} et al.), flushes the
response, and
+ * calls {@link AsyncContext#complete()}.
+ * <li>On failure — runs the throwable through {@link
RestContext#convertThrowable(Throwable)} so
+ * the existing error pipeline (including {@link
ThrowableProcessor} and
+ * {@link ProblemDetailsProcessor}) handles it, then completes the
{@code AsyncContext}.
+ * </ul>
+ *
+ * <h5 class='section'>Synchronous fallback</h5>
+ * <p>
+ * In environments where {@link HttpServletRequest#startAsync()} is
unsupported (most notably
+ * Juneau's {@code MockServletRequest}, which returns {@code null}), this
processor falls back to a
+ * blocking {@link CompletableFuture#get(long, TimeUnit) get(timeout)} on the
future and then
+ * returns {@link #RESTART} so the rest of the chain runs synchronously on the
unwrapped value.
+ * This keeps the unit-test surface working without requiring a real servlet
container while
+ * preserving full {@code AsyncContext} semantics in production.
+ *
+ * <h5 class='section'>Timeout</h5>
+ * <p>
+ * The async timeout is configurable via {@code @Rest(asyncTimeoutMillis)} /
+ * {@code @RestOp(asyncTimeoutMillis)} (default 30s). On timeout the processor
cancels the future
+ * with {@code mayInterruptIfRunning=true} and writes a {@link
HttpServletResponse#SC_GATEWAY_TIMEOUT}
+ * response.
+ *
+ * <h5 class='section'>Bare {@link Future} rejection</h5>
+ * <p>
+ * Bare {@link Future} return types (anything that is a {@code Future} but not
a
+ * {@link CompletionStage}) are rejected with an {@link InternalServerError} —
+ * polling a {@code Future} would block the request thread without any of the
cancellation /
+ * cooperation guarantees that {@link CompletableFuture} provides. Handlers
that need async dispatch
+ * must return {@link CompletableFuture} or {@link CompletionStage}.
+ *
+ * <h5 class='section'>Thread-local caveats</h5>
+ * <p>
+ * Anything carried via {@link ThreadLocal} (SLF4J MDC, security contexts)
does NOT survive the
+ * async boundary — the {@code whenComplete} callback typically runs on a
different thread than the
+ * one that produced the future. {@link RequestAttributes}, {@link
org.apache.juneau.svl.VarResolverSession},
+ * and {@link java.util.Locale} are request-scoped and survive correctly.
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * <li class='link'><a class="doclink"
href="https://juneau.apache.org/docs/topics/RestServerAsync">Async Response
Handling</a>
+ * <li class='link'><a class="doclink"
href="https://juneau.apache.org/docs/topics/ResponseProcessors">Response
Processors</a>
+ * </ul>
+ *
+ * @since 9.5.0
+ */
+public class AsyncResponseProcessor implements ResponseProcessor {
+
+ private static final Logger LOG =
Logger.getLogger(AsyncResponseProcessor.class.getName());
+
+ /**
+ * Request attribute set by this processor when the {@code
AsyncContext} path is taken so
+ * downstream lifecycle code (specifically {@link
RestOpSession#finish()} and
+ * {@link RestSession#finish()}) can skip the synchronous {@code
flushBuffer()} / {@code req.close()}
+ * — the {@code AsyncContext.complete()} call inside the {@code
whenComplete} callback is
+ * responsible for committing the response.
+ *
+ * @since 9.5.0
+ */
+ public static final String ATTR_ASYNC_DISPATCH_OWNED =
"org.apache.juneau.rest.async.dispatchOwned";
+
+ /** Default async timeout in milliseconds when no annotation override
is supplied. */
+ public static final long DEFAULT_ASYNC_TIMEOUT_MILLIS = 30_000L;
+
+ @Override /* Overridden from ResponseProcessor */
+ @SuppressWarnings({
+ "java:S3776", // Async dispatch logic is inherently branchy —
splitting further hurts readability.
+ "java:S1141" // Nested try/catch cleanly separates startAsync
IllegalStateException recovery from cancellation.
+ })
+ public int process(RestOpSession opSession) throws IOException,
BasicHttpException {
+ var res = opSession.getResponse();
+ var content = res.getContent().orElse(null);
+
+ if (content == null)
+ return NEXT;
+
+ if (content instanceof CompletionStage<?> stage)
+ return processAsync(opSession, stage);
+
+ if (content instanceof Future<?>) {
+ throw new InternalServerError(
+ "Bare java.util.concurrent.Future is not
supported as a return type from @RestOp methods. "
+ + "Return CompletableFuture or
CompletionStage instead, or block on the result yourself before returning."
+ );
+ }
+
+ return NEXT;
+ }
+
+ private int processAsync(RestOpSession opSession, CompletionStage<?>
stage) throws IOException, BasicHttpException {
+ var timeoutMs = resolveTimeoutMillis(opSession);
+ var cf = stage.toCompletableFuture();
+ var req = opSession.getRequest().getHttpServletRequest();
+
+ // True async path requires both isAsyncSupported() and a
non-null AsyncContext from startAsync().
+ AsyncContext asyncCtx = null;
+ if (req.isAsyncSupported()) {
+ try {
+ asyncCtx = req.startAsync();
+ if (asyncCtx != null && timeoutMs > 0)
+ asyncCtx.setTimeout(timeoutMs);
+ } catch (IllegalStateException e) {
+ asyncCtx = null; // Already committed or async
not actually supported on this request.
+ }
+ }
+
+ if (asyncCtx == null)
+ return processSyncFallback(opSession, cf, timeoutMs);
+
+ dispatchAsync(opSession, cf, asyncCtx, timeoutMs);
+ return FINISHED;
+ }
+
+ @SuppressWarnings({
+ "java:S2142", // We re-set the interrupt flag immediately and
surface as a 500.
+ "java:S1166" // CancellationException is intentionally
swallowed; we surface it as a 500 via convertThrowable.
+ })
+ private int processSyncFallback(RestOpSession opSession,
CompletableFuture<?> cf, long timeoutMs) {
+ var res = opSession.getResponse();
+ try {
+ Object value = (timeoutMs > 0) ? cf.get(timeoutMs,
TimeUnit.MILLISECONDS) : cf.get();
+ res.setContent(value);
+ return RESTART;
+ } catch (TimeoutException te) {
+ cf.cancel(true);
+ res.setStatus(SC_GATEWAY_TIMEOUT);
+
res.setContent(opSession.getRestContext().convertThrowable(
+ new GatewayTimeout("Async response timed out
after " + timeoutMs + "ms.")
+ ));
+ return RESTART;
+ } catch (CancellationException ce) {
+ res.setStatus(SC_INTERNAL_SERVER_ERROR);
+
res.setContent(opSession.getRestContext().convertThrowable(ce));
+ return RESTART;
+ } catch (ExecutionException ee) {
+ Throwable cause = ee.getCause() == null ? ee :
ee.getCause();
+ res.setStatus(SC_INTERNAL_SERVER_ERROR);
+
res.setContent(opSession.getRestContext().convertThrowable(cause));
+ return RESTART;
+ } catch (InterruptedException ie) {
+ Thread.currentThread().interrupt();
+ cf.cancel(true);
+ res.setStatus(SC_INTERNAL_SERVER_ERROR);
+
res.setContent(opSession.getRestContext().convertThrowable(ie));
+ return RESTART;
+ }
+ }
+
+ private void dispatchAsync(RestOpSession opSession,
CompletableFuture<?> cf, AsyncContext asyncCtx, long timeoutMs) {
+ var req = opSession.getRequest().getHttpServletRequest();
+ req.setAttribute(ATTR_ASYNC_DISPATCH_OWNED, Boolean.TRUE);
+
+ var done = new AtomicBoolean(false);
+
+ asyncCtx.addListener(new AsyncListener() {
+ @Override public void onComplete(AsyncEvent ev) { /*
no-op */ }
+ @Override public void onStartAsync(AsyncEvent ev) { /*
no-op */ }
+ @Override public void onError(AsyncEvent ev) {
+ finalizeAsync(opSession, cf, asyncCtx,
ev.getThrowable(), done, /* timeout */ false, timeoutMs);
+ }
+ @Override public void onTimeout(AsyncEvent ev) {
+ finalizeAsync(opSession, cf, asyncCtx, null,
done, /* timeout */ true, timeoutMs);
+ }
+ });
+
+ cf.whenComplete((value, error) ->
+ finalizeAsync(opSession, cf, asyncCtx, error, done, /*
timeout */ false, timeoutMs, value)
+ );
+ }
+
+ private void finalizeAsync(RestOpSession opSession,
CompletableFuture<?> cf, AsyncContext asyncCtx,
+ Throwable error, AtomicBoolean done, boolean timeout,
long timeoutMs) {
+ finalizeAsync(opSession, cf, asyncCtx, error, done, timeout,
timeoutMs, null);
+ }
+
+ @SuppressWarnings({
+ "java:S3776", // Async finalization is inherently branchy.
+ "java:S1141" // Nested try/catch separates response-processing
from container cleanup.
+ })
+ private void finalizeAsync(RestOpSession opSession,
CompletableFuture<?> cf, AsyncContext asyncCtx,
+ Throwable error, AtomicBoolean done, boolean timeout,
long timeoutMs, Object value) {
+ if (! done.compareAndSet(false, true))
+ return;
+
+ var res = opSession.getResponse();
+
+ try {
+ if (timeout) {
+ cf.cancel(true);
+ res.setStatus(SC_GATEWAY_TIMEOUT);
+
res.setContent(opSession.getRestContext().convertThrowable(
+ new GatewayTimeout("Async response
timed out after " + timeoutMs + "ms.")
+ ));
+ } else if (error != null) {
+ Throwable cause = unwrap(error);
+ res.setStatus(SC_INTERNAL_SERVER_ERROR);
+
res.setContent(opSession.getRestContext().convertThrowable(cause));
+ } else {
+ res.setContent(value);
+ }
+
+ opSession.getRestContext().processResponse(opSession);
+ res.flushBuffer();
+ } catch (Exception e) {
+ LOG.log(Level.WARNING, e, () -> "Async response
finalization failed: " + e.getMessage());
+ try {
+ if (!
res.getHttpServletResponse().isCommitted())
+
res.getHttpServletResponse().sendError(SC_INTERNAL_SERVER_ERROR);
+ } catch (Exception inner) {
+ LOG.log(Level.FINEST, inner, () -> "Async
response error-fallback also failed: " + inner.getMessage());
+ }
+ } finally {
+ try {
+ asyncCtx.complete();
+ } catch (IllegalStateException ise) {
+ LOG.log(Level.FINEST, ise, () ->
"AsyncContext.complete() raced with the container: " + ise.getMessage());
+ }
+ }
+ }
+
+ private static Throwable unwrap(Throwable t) {
+ if (t instanceof CompletionException && t.getCause() != null)
+ return t.getCause();
+ if (t instanceof ExecutionException && t.getCause() != null)
+ return t.getCause();
+ return t;
+ }
+
+ private static long resolveTimeoutMillis(RestOpSession opSession) {
+ var op = opSession.getContext().getAsyncTimeoutMillis();
+ if (op > 0)
+ return op;
+ var ctx = opSession.getRestContext().getAsyncTimeoutMillis();
+ return ctx > 0 ? ctx : DEFAULT_ASYNC_TIMEOUT_MILLIS;
+ }
+
+ /**
+ * Returns whether the given session has been handed off to async
dispatch. Inspected by
+ * {@link RestOpSession#finish()} / {@link RestSession#finish()} to
skip the synchronous
+ * {@code flushBuffer()} call when the {@code AsyncContext.complete()}
path will commit the
+ * response itself.
+ *
+ * @param opSession The session to check.
+ * @return {@code true} if {@link #ATTR_ASYNC_DISPATCH_OWNED} has been
set.
+ */
+ public static boolean isAsyncDispatchOwned(RestOpSession opSession) {
+ if (opSession == null)
+ return false;
+ return
isAsyncDispatchOwned(opSession.getRequest().getHttpServletRequest());
+ }
+
+ /**
+ * Variant of {@link #isAsyncDispatchOwned(RestOpSession)} that
operates directly on the
+ * underlying servlet request — used by {@link RestSession#finish()}
where the
+ * {@link RestOpSession} may not be available.
+ *
+ * @param req The servlet request.
+ * @return {@code true} if {@link #ATTR_ASYNC_DISPATCH_OWNED} has been
set.
+ */
+ public static boolean isAsyncDispatchOwned(HttpServletRequest req) {
+ if (req == null)
+ return false;
+ return
Boolean.TRUE.equals(req.getAttribute(ATTR_ASYNC_DISPATCH_OWNED));
+ }
+}
diff --git a/juneau-utest/pom.xml b/juneau-utest/pom.xml
index e06744284e..9a77bb3396 100644
--- a/juneau-utest/pom.xml
+++ b/juneau-utest/pom.xml
@@ -217,7 +217,7 @@
<dependency>
<groupId>org.thymeleaf</groupId>
<artifactId>thymeleaf</artifactId>
- <version>3.1.3.RELEASE</version>
+ <version>3.1.5.RELEASE</version>
<scope>test</scope>
</dependency>
<dependency>
@@ -389,19 +389,19 @@
<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-api</artifactId>
- <version>1.43.0</version>
+ <version>1.62.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-sdk</artifactId>
- <version>1.43.0</version>
+ <version>1.62.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-sdk-testing</artifactId>
- <version>1.43.0</version>
+ <version>1.62.0</version>
<scope>test</scope>
</dependency>
<dependency>
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/rest/VirtualThreadDispatch_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/rest/VirtualThreadDispatch_Test.java
new file mode 100644
index 0000000000..5a366a8a38
--- /dev/null
+++
b/juneau-utest/src/test/java/org/apache/juneau/rest/VirtualThreadDispatch_Test.java
@@ -0,0 +1,228 @@
+/*
+ * 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;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.junit.jupiter.api.condition.JRE.*;
+
+import java.util.concurrent.*;
+import java.util.logging.*;
+
+import org.apache.juneau.TestBase;
+import org.apache.juneau.json.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.junit.jupiter.api.*;
+import org.junit.jupiter.api.condition.*;
+
+/**
+ * Tests virtual-thread dispatch (TODO-70 Part 2) — opt-in via
+ * {@link Rest#virtualThreads() @Rest(virtualThreads=true)} or {@link
RestOp#virtualThreads()}, with
+ * graceful degradation on Java 17/18/19/20 (one-shot {@code WARNING} log +
caller-thread fallback).
+ *
+ * <p>
+ * Tests in section <b>A</b> are guarded by {@link DisabledOnJre} for any JVM
older than 21 — the
+ * underlying API ({@code Thread.currentThread().isVirtual()}) doesn't exist
before then. Tests in
+ * section <b>B</b> run on every JVM and verify the graceful-degradation
contract: enabling the flag
+ * on a non-supporting JVM must not break the resource and must emit a
one-shot warning.
+ *
+ * <h5 class='section'>Java 17 reflective compile-time strategy</h5>
+ * <p>
+ * The virtual-thread executor in {@link RestContext} is built reflectively
+ * ({@code Executors.class.getMethod("newVirtualThreadPerTaskExecutor")}) so
the framework compiles
+ * cleanly on Java 17 even though the API only exists at runtime on Java 21+.
The runtime check
+ * ({@code Runtime.version().feature() >= 21}) gates the reflective lookup;
on Java 17 we never
+ * touch the missing method and the {@code Executor} memoizer returns {@code
null} after logging the
+ * warning. {@link RestOpInvoker} then falls through to the standard
caller-thread dispatch path —
+ * exactly the existing pre-TODO-70 behavior.
+ */
+class VirtualThreadDispatch_Test extends TestBase {
+
+ //
-----------------------------------------------------------------------------------------------------------------
+ // A: Java 21+ — @Rest(virtualThreads=true) actually dispatches handler
invocation on a virtual thread.
+ //
-----------------------------------------------------------------------------------------------------------------
+
+ @Rest(virtualThreads = "true", serializers = JsonSerializer.class)
+ public static class A {
+ @RestGet("/which")
+ public ThreadInfo which() {
+ Thread t = Thread.currentThread();
+ return new ThreadInfo(t.getName(), reflectIsVirtual(t));
+ }
+
+ @RestGet("/asyncWhich")
+ public CompletableFuture<ThreadInfo> asyncWhich() {
+ Thread t = Thread.currentThread();
+ return CompletableFuture.completedFuture(new
ThreadInfo(t.getName(), reflectIsVirtual(t)));
+ }
+ }
+
+ /**
+ * Reflective {@code Thread.isVirtual()} so this test class compiles on
the project's Java 17 source level.
+ * On Java < 21 the method does not exist and we return {@code false} —
which is also the correct behavior
+ * since virtual threads cannot exist on those JVMs.
+ */
+ static boolean reflectIsVirtual(Thread t) {
+ try {
+ var m = Thread.class.getMethod("isVirtual");
+ return (Boolean) m.invoke(t);
+ } catch (NoSuchMethodException nsme) {
+ return false;
+ } catch (Exception e) {
+ return false;
+ }
+ }
+
+ public static final class ThreadInfo {
+ public final String threadName;
+ public final boolean virtual;
+ public ThreadInfo(String threadName, boolean virtual) {
this.threadName = threadName; this.virtual = virtual; }
+ }
+
+ private static final MockRestClient CA =
MockRestClient.buildLax(A.class);
+
+ @Test
+ @EnabledForJreRange(min = JAVA_21)
+ void a01_java21Plus_virtualThreadDispatch() throws Exception {
+
CA.get("/which").run().assertStatus(200).assertContent().isContains("\"virtual\":true");
+ }
+
+ @Test
+ @EnabledForJreRange(min = JAVA_21)
+ void
a02_java21Plus_virtualThreadDispatch_combinedWithCompletableFuture() throws
Exception {
+ // The handler runs on a virtual thread, returns a
CompletableFuture, and the response comes back unwrapped.
+ // This is the high-throughput pattern: VT + CompletableFuture
together.
+
CA.get("/asyncWhich").run().assertStatus(200).assertContent().isContains("\"virtual\":true");
+ }
+
+ //
-----------------------------------------------------------------------------------------------------------------
+ // B: Graceful degradation on Java 17/18/19/20 — flag is logged +
ignored, handlers still work.
+ //
-----------------------------------------------------------------------------------------------------------------
+
+ @Rest(virtualThreads = "true", serializers = JsonSerializer.class)
+ public static class B {
+ @RestGet("/ok")
+ public String ok() { return "ok"; }
+ }
+
+ @Test void b01_degradation_handlerStillWorks() throws Exception {
+ // Whether we're on Java 17 (graceful degradation) or Java 21
(real VT), the handler must respond.
+ var c = MockRestClient.buildLax(B.class);
+
c.get("/ok").run().assertStatus(200).assertContent().isContains("ok");
+ }
+
+ @Rest(virtualThreads = "true", serializers = JsonSerializer.class)
+ public static class BWarning {
+ @RestGet("/x")
+ public String x() { return "x"; }
+ }
+
+ @Test
+ @DisabledForJreRange(min = JAVA_21)
+ void b02_java17_logsWarningOnce() throws Exception {
+ // Capture the WARNING emitted by RestContext when
@Rest(virtualThreads=true) is configured on Java < 21.
+ var captured = new StringBuilder();
+ var logger = Logger.getLogger(RestContext.class.getName() +
".async");
+ var handler = new Handler() {
+ @Override public void publish(LogRecord r) {
+ if (r.getLevel() == Level.WARNING)
+ captured.append(r.getMessage());
+ }
+ @Override public void flush() {}
+ @Override public void close() {}
+ };
+ logger.addHandler(handler);
+ try {
+
MockRestClient.buildLax(BWarning.class).get("/x").run().assertStatus(200);
+
assertTrue(captured.toString().contains("virtual-thread") ||
captured.toString().contains("virtualThreads"),
+ "expected warning about virtual threads on Java
<21, captured: " + captured);
+ } finally {
+ logger.removeHandler(handler);
+ }
+ }
+
+ //
-----------------------------------------------------------------------------------------------------------------
+ // C: Per-op virtualThreads override — @RestOp(virtualThreads="false")
opts out of resource-level on.
+ //
-----------------------------------------------------------------------------------------------------------------
+
+ @Rest(virtualThreads = "true", serializers = JsonSerializer.class)
+ public static class C {
+ @RestOp(method = "GET", path = "/optedIn")
+ public ThreadInfo optedIn() {
+ Thread t = Thread.currentThread();
+ return new ThreadInfo(t.getName(), reflectIsVirtual(t));
+ }
+
+ @RestOp(method = "GET", path = "/optedOut", virtualThreads =
"false")
+ public ThreadInfo optedOut() {
+ Thread t = Thread.currentThread();
+ return new ThreadInfo(t.getName(), reflectIsVirtual(t));
+ }
+ }
+
+ @Test
+ @EnabledForJreRange(min = JAVA_21)
+ void c01_perOpOptOutOverridesResourceLevel() throws Exception {
+ var c = MockRestClient.buildLax(C.class);
+
c.get("/optedIn").run().assertStatus(200).assertContent().isContains("\"virtual\":true");
+
c.get("/optedOut").run().assertStatus(200).assertContent().isContains("\"virtual\":false");
+ }
+
+ @Test
+ @EnabledForJreRange(min = JAVA_21)
+ void c02_perOpOptInWithoutResourceLevel() throws Exception {
+ var d = MockRestClient.buildLax(D.class);
+
d.get("/onlyThisOne").run().assertStatus(200).assertContent().isContains("\"virtual\":true");
+
d.get("/notThisOne").run().assertStatus(200).assertContent().isContains("\"virtual\":false");
+ }
+
+ @Rest(serializers = JsonSerializer.class)
+ public static class D {
+ @RestOp(method = "GET", path = "/onlyThisOne", virtualThreads =
"true")
+ public ThreadInfo only() {
+ Thread t = Thread.currentThread();
+ return new ThreadInfo(t.getName(), reflectIsVirtual(t));
+ }
+
+ @RestOp(method = "GET", path = "/notThisOne")
+ public ThreadInfo notMe() {
+ Thread t = Thread.currentThread();
+ return new ThreadInfo(t.getName(), reflectIsVirtual(t));
+ }
+ }
+
+ //
-----------------------------------------------------------------------------------------------------------------
+ // E: Default off — no annotation means no virtual-thread dispatch even
on Java 21+.
+ //
-----------------------------------------------------------------------------------------------------------------
+
+ @Rest(serializers = JsonSerializer.class)
+ public static class E {
+ @RestGet("/default")
+ public ThreadInfo def() {
+ Thread t = Thread.currentThread();
+ return new ThreadInfo(t.getName(), reflectIsVirtual(t));
+ }
+ }
+
+ private static final MockRestClient CE =
MockRestClient.buildLax(E.class);
+
+ @Test void d01_offByDefault() throws Exception {
+ // On any JVM, the unannotated handler runs on the caller
(request) thread — never virtual.
+ // (On Java 17, isVirtual() is always false; on Java 21+ this
verifies the off-by-default contract.)
+
CE.get("/default").run().assertStatus(200).assertContent().isContains("\"virtual\":false");
+ }
+}
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/rest/processor/AsyncResponseProcessor_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/rest/processor/AsyncResponseProcessor_Test.java
new file mode 100644
index 0000000000..9971efb917
--- /dev/null
+++
b/juneau-utest/src/test/java/org/apache/juneau/rest/processor/AsyncResponseProcessor_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.rest.processor;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.concurrent.*;
+
+import org.apache.juneau.TestBase;
+import org.apache.juneau.http.annotation.*;
+import org.apache.juneau.http.response.*;
+import org.apache.juneau.json.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests {@link AsyncResponseProcessor} — the new processor (TODO-70) that
unwraps
+ * {@link CompletableFuture} / {@link CompletionStage} return values from
{@code @RestOp} methods
+ * and bridges them to the servlet container's {@link
jakarta.servlet.AsyncContext} lifecycle.
+ *
+ * <p>
+ * In the {@code MockRestClient} test harness the underlying {@code
MockServletRequest} reports
+ * {@code isAsyncSupported() == false}, so this processor exercises its
synchronous-fallback path
+ * (block on the future with the configured timeout) which is exactly the path
that test suites
+ * outside a real servlet container will take. The async {@code
AsyncContext}-driven path is
+ * covered by integration tests against real containers; here we verify the
unwrap, error
+ * propagation, timeout, and bare-{@code Future} rejection contracts.
+ */
+class AsyncResponseProcessor_Test extends TestBase {
+
+ //
-----------------------------------------------------------------------------------------------------------------
+ // A: Happy path — CompletableFuture<String> handler returns the
unwrapped string body.
+ //
-----------------------------------------------------------------------------------------------------------------
+
+ @Rest(serializers = JsonSerializer.class)
+ public static class A {
+ @RestGet("/sync")
+ public CompletableFuture<String> sync() {
+ return CompletableFuture.completedFuture("hello-async");
+ }
+
+ @RestGet("/asyncSupplier")
+ public CompletableFuture<String> asyncSupplier() {
+ return CompletableFuture.supplyAsync(() ->
"supplied-async");
+ }
+
+ @RestGet("/completionStage")
+ public CompletionStage<String> completionStage() {
+ return CompletableFuture.completedStage("stage-value");
+ }
+
+ @RestGet("/pojo")
+ public CompletableFuture<Pojo> pojo() {
+ return CompletableFuture.completedFuture(new
Pojo("foo", 42));
+ }
+
+ @RestGet("/voidContent")
+ public CompletableFuture<Void> voidContent() {
+ return CompletableFuture.completedFuture(null);
+ }
+ }
+
+ public static final class Pojo {
+ public final String name;
+ public final int value;
+ public Pojo(String name, int value) { this.name = name;
this.value = value; }
+ }
+
+ private static final MockRestClient CA =
MockRestClient.buildLax(A.class);
+
+ @Test void a01_completableFutureString_unwrapsBody() throws Exception {
+
CA.get("/sync").run().assertStatus(200).assertContent().isContains("hello-async");
+ }
+
+ @Test void a02_completableFutureSupplyAsync_unwrapsBody() throws
Exception {
+
CA.get("/asyncSupplier").run().assertStatus(200).assertContent().isContains("supplied-async");
+ }
+
+ @Test void a03_completionStage_unwrapsBody() throws Exception {
+
CA.get("/completionStage").run().assertStatus(200).assertContent().isContains("stage-value");
+ }
+
+ @Test void a04_completableFuturePojo_serializesBean() throws Exception {
+ CA.get("/pojo").accept("application/json").run()
+ .assertStatus(200)
+ .assertContent().isContains("\"name\":\"foo\"",
"\"value\":42");
+ }
+
+ @Test void a05_completableFutureVoid_emitsNull() throws Exception {
+ CA.get("/voidContent").run().assertStatus(200);
+ }
+
+ //
-----------------------------------------------------------------------------------------------------------------
+ // B: Error path — failed future routes through the existing error
pipeline.
+ //
-----------------------------------------------------------------------------------------------------------------
+
+ @Rest
+ public static class B {
+ @RestGet("/notFound")
+ public CompletableFuture<String> notFound() {
+ var f = new CompletableFuture<String>();
+ f.completeExceptionally(new NotFound("Resource not
found."));
+ return f;
+ }
+
+ @RestGet("/internalServerError")
+ public CompletableFuture<String> ise() {
+ var f = new CompletableFuture<String>();
+ f.completeExceptionally(new
IllegalStateException("kaboom"));
+ return f;
+ }
+
+ @RestGet("/badRequest/{id}")
+ public CompletableFuture<String> badRequest(@Path String id) {
+ var f = new CompletableFuture<String>();
+ f.completeExceptionally(new BadRequest("Bad id:
''{0}''", id));
+ return f;
+ }
+ }
+
+ private static final MockRestClient CB =
MockRestClient.buildLax(B.class);
+
+ @Test void b01_failedFutureWithNotFound_returns404() throws Exception {
+ CB.get("/notFound").run().assertStatus(404);
+ }
+
+ @Test void b02_failedFutureWithGenericException_returns500() throws
Exception {
+ CB.get("/internalServerError").run().assertStatus(500);
+ }
+
+ @Test void b03_failedFutureWithBadRequest_returns400() throws Exception
{
+ CB.get("/badRequest/abc").run().assertStatus(400);
+ }
+
+ //
-----------------------------------------------------------------------------------------------------------------
+ // C: Timeout path — never-completing future writes a 504.
+ //
-----------------------------------------------------------------------------------------------------------------
+
+ @Rest(asyncTimeoutMillis = "150")
+ public static class C {
+ @RestGet("/never")
+ public CompletableFuture<String> never() {
+ return new CompletableFuture<>(); // Never completes.
+ }
+ }
+
+ private static final MockRestClient CC =
MockRestClient.buildLax(C.class);
+
+ @Test void c01_neverCompletingFuture_returns504OnTimeout() throws
Exception {
+ CC.get("/never").run().assertStatus(504);
+ }
+
+ //
-----------------------------------------------------------------------------------------------------------------
+ // D: Per-op asyncTimeoutMillis override wins over the resource-level
setting.
+ //
-----------------------------------------------------------------------------------------------------------------
+
+ @Rest(asyncTimeoutMillis = "5000") // Resource default is 5s.
+ public static class D {
+ @RestOp(method = "GET", path = "/never", asyncTimeoutMillis =
"100") // Op-level wins.
+ public CompletableFuture<String> never() {
+ return new CompletableFuture<>();
+ }
+ }
+
+ private static final MockRestClient CD =
MockRestClient.buildLax(D.class);
+
+ @Test void d01_perOpTimeoutOverride() throws Exception {
+ long before = System.currentTimeMillis();
+ CD.get("/never").run().assertStatus(504);
+ long elapsed = System.currentTimeMillis() - before;
+ assertTrue(elapsed < 2000, "per-op 100ms timeout should fire
well before the 5s resource default — actual " + elapsed + "ms");
+ }
+
+ //
-----------------------------------------------------------------------------------------------------------------
+ // E: Bare Future rejection — anything that is a Future but not a
CompletionStage is rejected with 500.
+ //
-----------------------------------------------------------------------------------------------------------------
+
+ @Rest
+ public static class E {
+ @RestGet("/bare")
+ public Future<String> bareFuture() {
+ // FutureTask is a bare Future — not a CompletionStage.
+ return new FutureTask<>(() -> "should-not-be-blocking");
+ }
+ }
+
+ private static final MockRestClient CE =
MockRestClient.buildLax(E.class);
+
+ @Test void e01_bareFuture_rejected() throws Exception {
+
CE.get("/bare").run().assertStatus(500).assertContent().isContains("Bare
java.util.concurrent.Future");
+ }
+
+ //
-----------------------------------------------------------------------------------------------------------------
+ // F: Synchronous handlers (no CompletableFuture) are unchanged —
backward-compat smoke test.
+ //
-----------------------------------------------------------------------------------------------------------------
+
+ @Rest
+ public static class F {
+ @RestGet("/sync")
+ public String sync() { return "still-synchronous"; }
+
+ @RestGet("/null")
+ public String nullReturn() { return null; }
+ }
+
+ private static final MockRestClient CF =
MockRestClient.buildLax(F.class);
+
+ @Test void f01_syncString_unchanged() throws Exception {
+
CF.get("/sync").run().assertStatus(200).assertContent().isContains("still-synchronous");
+ }
+
+ @Test void f02_syncNull_unchanged() throws Exception {
+ CF.get("/null").run().assertStatus(200);
+ }
+}