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

tballison pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tika.git


The following commit(s) were added to refs/heads/main by this push:
     new 9ef8bfade7 TIKA-4991: apply read deadline only to reads that would 
wait on the c… (#3404)
9ef8bfade7 is described below

commit 9ef8bfade74caedde4089d83a74ae13ec33b72c4
Author: Tim Allison <[email protected]>
AuthorDate: Fri Oct 23 11:50:24 2026 -0400

    TIKA-4991: apply read deadline only to reads that would wait on the c… 
(#3404)
    
    * TIKA-4991: apply read deadline only to reads that would wait on the client
    
      CXF reads the multipart closing boundary after the resource returns, so a
      /form parse longer than maxRequestReadMillis was answered 408 and its 
result
      discarded. Skip the deadline when the servlet input is finished or has 
bytes
      buffered; drop the redundant entity-level deadline.
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 CHANGES.txt                                        |  4 +-
 .../ROOT/pages/using-tika/server/index.adoc        |  2 +-
 .../tika/server/core/MaxRequestSizeFilter.java     | 52 ++++++++++++----------
 .../core/RequestReadDeadlineInterceptor.java       |  3 --
 .../apache/tika/server/core/TikaServerConfig.java  |  5 ++-
 .../apache/tika/server/core/TikaServerProcess.java |  3 +-
 .../standard/MaxRequestSizeIntegrationTest.java    | 23 ++++++++++
 7 files changed, 58 insertions(+), 34 deletions(-)

diff --git a/CHANGES.txt b/CHANGES.txt
index 27646c9141..71cc4dac73 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -55,8 +55,8 @@ Release 4.2.0 - 10/22/2026
      not SizeGatedEnricher (TIKA-4972).
 
    * tika-server adds transfer limits: maxRequestReadMillis (408) and
-     maxResponseWriteMillis, both 5 min; idleTimeoutMillis (120s); and
-     maxInFlightRequestBytes (429). Raise the deadlines for very large or very 
slow
+     maxResponseWriteMillis, both 5 min; idleTimeoutMillis (120s, was CXF's 
fixed
+     200s); and maxInFlightRequestBytes (429). Raise the deadlines for very 
large or very slow
      transfers; cut-offs are logged at INFO by the TransferLimitLog logger 
(TIKA-4991).
 
    * tika-server accepts cleartext HTTP/2 (h2c) only when allowHttp2 is set; 
it had
diff --git a/docs/modules/ROOT/pages/using-tika/server/index.adoc 
b/docs/modules/ROOT/pages/using-tika/server/index.adoc
index fcb41e95b3..743596ee47 100644
--- a/docs/modules/ROOT/pages/using-tika/server/index.adoc
+++ b/docs/modules/ROOT/pages/using-tika/server/index.adoc
@@ -720,7 +720,7 @@ independent forked-process groups you run — see
 
 |`idleTimeoutMillis`
 |`120000` (2 min)
-|How long a connection may go without any bytes moving before it is closed. 
This is what ends a client that stops sending or reading altogether, which the 
read and write deadlines cannot. A request waiting on its parse is not 
affected, however long the parse takes. Must be positive: it cannot be 
disabled. Every request cut off by this or by the size and deadline limits 
above is logged once, at `INFO`, by the 
`org.apache.tika.server.core.TransferLimitLog` logger, so these client-caused 
ev [...]
+|How long a connection may go without any bytes moving before it is closed. 
This is what ends a client that stops sending or reading altogether, which the 
read and write deadlines cannot. A request waiting on its parse is not 
affected, however long the parse takes: Jetty applies the idle timeout only to 
a read or write that is waiting on the wire, so no setting here ends a parse; 
the xref:pipes/timeouts.adoc[task timeouts] do that. Must be positive: it 
cannot be disabled. Every request c [...]
 
 |`allowHttp2`
 |`false`
diff --git 
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/MaxRequestSizeFilter.java
 
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/MaxRequestSizeFilter.java
index 97db65b9fa..983da171e2 100644
--- 
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/MaxRequestSizeFilter.java
+++ 
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/MaxRequestSizeFilter.java
@@ -19,9 +19,9 @@ package org.apache.tika.server.core;
 import java.io.FilterInputStream;
 import java.io.IOException;
 import java.io.InputStream;
-import java.util.concurrent.TimeUnit;
 import java.util.concurrent.TimeoutException;
 
+import jakarta.servlet.ServletInputStream;
 import jakarta.ws.rs.container.ContainerRequestContext;
 import jakarta.ws.rs.container.ContainerRequestFilter;
 import jakarta.ws.rs.core.HttpHeaders;
@@ -30,7 +30,6 @@ import jakarta.ws.rs.core.Response;
 import jakarta.ws.rs.ext.ExceptionMapper;
 import jakarta.ws.rs.ext.Provider;
 import org.apache.cxf.jaxrs.utils.JAXRSUtils;
-import org.apache.cxf.message.Message;
 
 /**
  * Rejects request bodies larger than {@code maxRequestSizeBytes} (413), still 
arriving
@@ -40,8 +39,8 @@ import org.apache.cxf.message.Message;
  * A declared Content-Length over the limit is refused before the body is 
read. Requests
  * without a usable Content-Length -- chunked transfer encoding, in particular 
-- are
  * counted as they are consumed, so the limit holds whether or not the client 
is honest
- * about the size. The wire under the entity is bounded by
- * {@link RequestReadDeadlineInterceptor}, whose start time this deadline 
shares.
+ * about the size. The 408 comes from {@link RequestReadDeadlineInterceptor}, 
which puts
+ * the deadline on the wire under this stream.
  */
 @Provider
 public class MaxRequestSizeFilter implements ContainerRequestFilter {
@@ -54,25 +53,21 @@ public class MaxRequestSizeFilter implements 
ContainerRequestFilter {
     private static final long RETRY_AFTER_SECONDS = 1;
 
     private final long maxRequestSizeBytes;
-    private final long maxRequestReadMillis;
     private final RequestBodyBudget budget;
 
     /**
      * @param maxRequestSizeBytes maximum request body in bytes; negative 
disables the limit
      */
     public MaxRequestSizeFilter(long maxRequestSizeBytes) {
-        this(maxRequestSizeBytes, -1, null);
+        this(maxRequestSizeBytes, null);
     }
 
     /**
      * @param maxRequestSizeBytes maximum request body in bytes; negative 
disables the limit
-     * @param maxRequestReadMillis how long reading the body may take; 
negative disables it
      * @param budget bytes all requests may hold at once; null disables it
      */
-    public MaxRequestSizeFilter(long maxRequestSizeBytes, long 
maxRequestReadMillis,
-                                RequestBodyBudget budget) {
+    public MaxRequestSizeFilter(long maxRequestSizeBytes, RequestBodyBudget 
budget) {
         this.maxRequestSizeBytes = maxRequestSizeBytes;
-        this.maxRequestReadMillis = maxRequestReadMillis;
         this.budget = budget;
     }
 
@@ -84,19 +79,11 @@ public class MaxRequestSizeFilter implements 
ContainerRequestFilter {
             requestContext.abortWith(tooLarge());
             return;
         }
-        long maxReadNanos = maxRequestReadMillis < 0 ? -1 : 
TimeUnit.MILLISECONDS.toNanos(maxRequestReadMillis);
         RequestBodyBudget.Reservation reservation =
                 budget == null ? null : 
budget.open(JAXRSUtils.getCurrentMessage());
         // Entity bytes: after any Content-Encoding is undone.
-        requestContext.setEntityStream(new 
BoundedInputStream(requestContext.getEntityStream(),
-                maxRequestSizeBytes, startNanos(), maxReadNanos, 
maxRequestReadMillis, reservation));
-    }
-
-    private static long startNanos() {
-        Message message = JAXRSUtils.getCurrentMessage();
-        Object start = message == null || message.getExchange() == null
-                ? null : 
message.getExchange().get(RequestReadDeadlineInterceptor.START_NANOS);
-        return start instanceof Long l ? l : System.nanoTime();
+        requestContext.setEntityStream(
+                new BoundedInputStream(requestContext.getEntityStream(), 
maxRequestSizeBytes, reservation));
     }
 
     private static Response tooLarge() {
@@ -110,8 +97,10 @@ public class MaxRequestSizeFilter implements 
ContainerRequestFilter {
     /**
      * Throws once more than {@code limit} bytes have been read, or on a read 
after the
      * deadline. Deliberately not silent truncation: a caller that sent too 
much must not
-     * receive a 200 describing a prefix of their document. A client that 
sends nothing at
-     * all is left to the connector's idle timeout.
+     * receive a 200 describing a prefix of their document. The deadline 
applies only to a
+     * read that would wait on the client: bytes it already sent, such as a 
multipart closing
+     * boundary CXF reads after a long parse, are not late. A client that 
sends nothing at all
+     * is left to the connector's idle timeout.
      */
     static final class BoundedInputStream extends FilterInputStream {
 
@@ -122,6 +111,11 @@ public class MaxRequestSizeFilter implements 
ContainerRequestFilter {
         private final RequestBodyBudget.Reservation reservation;
         private long count;
 
+        /** Size and budget only; the deadline lives on the wire stream under 
this one. */
+        BoundedInputStream(InputStream in, long limit, 
RequestBodyBudget.Reservation reservation) {
+            this(in, limit, -1, -1, -1, reservation);
+        }
+
         BoundedInputStream(InputStream in, long limit, long startNanos, long 
maxReadNanos,
                            long maxReadMillis, RequestBodyBudget.Reservation 
reservation) {
             super(in);
@@ -190,12 +184,22 @@ public class MaxRequestSizeFilter implements 
ContainerRequestFilter {
             return skipped;
         }
 
-        private void checkDeadline() {
-            if (maxReadNanos >= 0 && System.nanoTime() - startNanos > 
maxReadNanos) {
+        private void checkDeadline() throws IOException {
+            if (maxReadNanos >= 0 && System.nanoTime() - startNanos > 
maxReadNanos && wouldBlock()) {
                 throw new RequestTimeoutException(TOO_SLOW_MESSAGE + " (" + 
maxReadMillis + " ms)");
             }
         }
 
+        // On Jetty's servlet stream in blocking mode, available() pulls the 
next chunk without
+        // blocking and isReady() is defined as available() > 0; in async mode 
isReady() would
+        // register read demand as a side effect, so it is not used here.
+        private boolean wouldBlock() throws IOException {
+            if (in instanceof ServletInputStream servletIn && 
servletIn.isFinished()) {
+                return false;
+            }
+            return in.available() == 0;
+        }
+
         private void add(int n) {
             count += n;
             if (limit >= 0 && count > limit) {
diff --git 
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/RequestReadDeadlineInterceptor.java
 
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/RequestReadDeadlineInterceptor.java
index cf20dd3fe4..40747d5a3e 100644
--- 
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/RequestReadDeadlineInterceptor.java
+++ 
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/RequestReadDeadlineInterceptor.java
@@ -33,8 +33,6 @@ import org.apache.cxf.phase.Phase;
  */
 public class RequestReadDeadlineInterceptor extends 
AbstractPhaseInterceptor<Message> {
 
-    static final String START_NANOS = 
RequestReadDeadlineInterceptor.class.getName() + ".startNanos";
-
     // must be on the exchange: unset there, CXF's OutgoingChainInterceptor 
sets it true on the
     // in-message and the unread body of a 2xx response is spooled to disk
     private static final String CXF_CACHE_INPUT = "cxf.io.cacheinput";
@@ -57,7 +55,6 @@ public class RequestReadDeadlineInterceptor extends 
AbstractPhaseInterceptor<Mes
         }
         long startNanos = System.nanoTime();
         exchange.put(CXF_CACHE_INPUT, Boolean.FALSE);
-        exchange.put(START_NANOS, startNanos);
         DelegatingInputStream wire = 
message.getContent(DelegatingInputStream.class);
         if (maxRequestReadMillis >= 0 && wire != null) {
             // deadline only: a size cap here would cut off a client that 
reads only after sending
diff --git 
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/TikaServerConfig.java
 
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/TikaServerConfig.java
index 16513ef886..f9a5a9f910 100644
--- 
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/TikaServerConfig.java
+++ 
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/TikaServerConfig.java
@@ -421,8 +421,9 @@ public class TikaServerConfig {
 
     /**
      * How long a connection may go without any bytes moving before it is 
closed; defaults to
-     * {@link #DEFAULT_IDLE_TIMEOUT_MILLIS} (2 minutes). Must be positive: 
Jetty treats zero
-     * as "use its own default", not "never".
+     * {@link #DEFAULT_IDLE_TIMEOUT_MILLIS} (2 minutes). Jetty applies it only 
to a read or
+     * write waiting on the wire, so a request waiting on its parse is never 
ended by it.
+     * Must be positive: Jetty treats zero as "use its own default", not 
"never".
      */
     public int getIdleTimeoutMillis() {
         return idleTimeoutMillis;
diff --git 
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/TikaServerProcess.java
 
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/TikaServerProcess.java
index dbc9f0ac12..e1bd47206b 100644
--- 
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/TikaServerProcess.java
+++ 
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/TikaServerProcess.java
@@ -493,8 +493,7 @@ public class TikaServerProcess {
 
         // Add ConfigEndpointSecurityFilter to gate /config endpoints
         writers.add(new 
ConfigEndpointSecurityFilter(tikaServerConfig.isAllowPerRequestConfig()));
-        writers.add(new 
MaxRequestSizeFilter(tikaServerConfig.getMaxRequestSizeBytes(),
-                tikaServerConfig.getMaxRequestReadMillis(), bodyBudget));
+        writers.add(new 
MaxRequestSizeFilter(tikaServerConfig.getMaxRequestSizeBytes(), bodyBudget));
         // 413 for over-limit chunked bodies, which bypass the Content-Length 
pre-check.
         writers.add(new MaxRequestSizeFilter.RequestTooLargeExceptionMapper());
         writers.add(new MaxRequestSizeFilter.RequestTimeoutExceptionMapper());
diff --git 
a/tika-server/tika-server-standard/src/test/java/org/apache/tika/server/standard/MaxRequestSizeIntegrationTest.java
 
b/tika-server/tika-server-standard/src/test/java/org/apache/tika/server/standard/MaxRequestSizeIntegrationTest.java
index 82f2625557..0aba6d18dc 100644
--- 
a/tika-server/tika-server-standard/src/test/java/org/apache/tika/server/standard/MaxRequestSizeIntegrationTest.java
+++ 
b/tika-server/tika-server-standard/src/test/java/org/apache/tika/server/standard/MaxRequestSizeIntegrationTest.java
@@ -215,6 +215,29 @@ public class MaxRequestSizeIntegrationTest extends 
IntegrationTestBase {
         }
     }
 
+    /**
+     * CXF reads the multipart closing boundary only after the resource 
returns; a parse that
+     * outlasts the read deadline must not turn into a 408 there. Load can 
only make the mock's
+     * hang longer, so timing cannot produce a false failure here.
+     */
+    @Test
+    public void testParseOutlastingReadDeadlineMultipart() throws Exception {
+        String mock = "<mock><write element=\"p\">slow parse done</write><hang 
millis=\""
+                + 2 * MAX_READ_MILLIS + "\" heavy=\"false\" 
pulse_millis=\"100\"/></mock>";
+        String body = "--" + BOUNDARY + "\r\nContent-Disposition: form-data; 
name=\"file\"; "
+                + "filename=\"mock.xml\"\r\nContent-Type: 
application/mock+xml\r\n\r\n"
+                + mock + "\r\n--" + BOUNDARY + "--\r\n";
+        HttpResponse<String> response = 
HttpClient.newHttpClient().send(HttpRequest
+                .newBuilder(URI.create("http://localhost:"; + 
integrationTestPort + "/rmeta/form"))
+                .header("Content-Type", "multipart/form-data; boundary=" + 
BOUNDARY)
+                .header("Accept", "application/json")
+                .POST(HttpRequest.BodyPublishers.ofString(body, UTF_8))
+                .build(), HttpResponse.BodyHandlers.ofString(UTF_8));
+        assertEquals(200, response.statusCode(), response.body());
+        // proves the mock parser ran, so the parse really did outlast the 
deadline
+        assertTrue(response.body().contains("slow parse done"), 
response.body());
+    }
+
     private static void assertWithinReadDeadline(String expected, String[] 
reply) {
         assertEquals(expected, reply[0], "after " + reply[1] + " ms");
         // the idle timeout alone would answer only once the drip ends

Reply via email to