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 &lt;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> &mdash; enable virtual-thread dispatch on 
Java 21+ (silently disabled on older JVMs).
+        *      <li><js>"false"</js> &mdash; explicitly disable.
+        *      <li><js>""</js> (default) &mdash; 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> &mdash; enable virtual-thread dispatch for 
this operation (Java 21+ only;
+        *              gracefully ignored on older JVMs).
+        *      <li><js>"false"</js> &mdash; disable virtual-thread dispatch 
for this operation (overrides an
+        *              opted-in resource).
+        *      <li><js>""</js> (default) &mdash; 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() &gt;= 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);
+       }
+}

Reply via email to