Improve EntityInputStream#isEmpty handling for the Servlet case. (#5806)

Signed-off-by: Maxim Nesen <maxim.nesen@oracle.com>
diff --git a/containers/jersey-servlet-core/src/main/java/org/glassfish/jersey/servlet/ServletEntityInputStream.java b/containers/jersey-servlet-core/src/main/java/org/glassfish/jersey/servlet/ServletEntityInputStream.java
new file mode 100644
index 0000000..fcbee2d
--- /dev/null
+++ b/containers/jersey-servlet-core/src/main/java/org/glassfish/jersey/servlet/ServletEntityInputStream.java
@@ -0,0 +1,109 @@
+/*
+ * Copyright (c) 2024, 2025 Oracle and/or its affiliates. All rights reserved.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License v. 2.0, which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the
+ * Eclipse Public License v. 2.0 are satisfied: GNU General Public License,
+ * version 2 with the GNU Classpath Exception, which is available at
+ * https://www.gnu.org/software/classpath/license.html.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
+ */
+
+package org.glassfish.jersey.servlet;
+
+import jakarta.servlet.ReadListener;
+import jakarta.servlet.ServletInputStream;
+import org.glassfish.jersey.innate.io.StreamListener;
+import org.glassfish.jersey.innate.io.StreamListenerCouple;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.UncheckedIOException;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+abstract class ServletEntityInputStream implements StreamListenerCouple {
+
+    private final boolean waitForInputEnable;
+    private final long waitForInputTimeOut;
+
+    private final StreamListener listener = new StreamListener() {
+
+        @Override
+        public boolean isEmpty() {
+            try {
+                return getWrappedStream().available() == 0
+                        || getWrappedStream().isFinished();
+            } catch (IOException e) {
+                throw new UncheckedIOException(e);
+            }
+        }
+
+        @Override
+        public boolean isReady() {
+            return processReadiness();
+        }
+
+        boolean processReadiness() {
+
+            final AtomicBoolean ready = new AtomicBoolean(getWrappedStream().isReady());
+            if (waitForInputEnable && !ready.get()) {
+
+                final CountDownLatch latch = new CountDownLatch(1);
+
+                getWrappedStream().setReadListener(new ReadListener() {
+                    @Override
+                    public void onDataAvailable() {
+                        ready.set(true);
+                        latch.countDown();
+                    }
+
+                    @Override
+                    public void onAllDataRead() {
+                        ready.set(false);
+                        latch.countDown();
+                    }
+
+                    @Override
+                    public void onError(Throwable t) {
+                        ready.set(false);
+                        latch.countDown();
+                    }
+
+                });
+                if (!ready.get()) {
+                    try {
+                        latch.await(waitForInputTimeOut, TimeUnit.MILLISECONDS);
+                    } catch (InterruptedException e) {
+                        ready.set(getWrappedStream().isReady());
+                    }
+                }
+            }
+            return ready.get() || getWrappedStream().isReady();
+        }
+
+    };
+
+    public ServletEntityInputStream(boolean waitForInputEnable, long waitForInputTimeOut) {
+        this.waitForInputEnable = waitForInputEnable;
+        this.waitForInputTimeOut = waitForInputTimeOut;
+    }
+
+    protected abstract ServletInputStream getWrappedStream();
+
+    @Override
+    public StreamListener getListener() {
+        return listener;
+    }
+
+    @Override
+    public InputStream getExternalStream() {
+        return getWrappedStream();
+    }
+}
diff --git a/containers/jersey-servlet-core/src/main/java/org/glassfish/jersey/servlet/ServletProperties.java b/containers/jersey-servlet-core/src/main/java/org/glassfish/jersey/servlet/ServletProperties.java
index 3689e62..bb3a52a 100644
--- a/containers/jersey-servlet-core/src/main/java/org/glassfish/jersey/servlet/ServletProperties.java
+++ b/containers/jersey-servlet-core/src/main/java/org/glassfish/jersey/servlet/ServletProperties.java
@@ -1,5 +1,5 @@
 /*
- * Copyright (c) 2012, 2020 Oracle and/or its affiliates. All rights reserved.
+ * Copyright (c) 2012, 2025 Oracle and/or its affiliates. All rights reserved.
  *
  * This program and the accompanying materials are made available under the
  * terms of the Eclipse Public License v. 2.0, which is available at
@@ -139,6 +139,30 @@
     public static final String QUERY_PARAMS_AS_FORM_PARAMS_DISABLED = "jersey.config.servlet.form.queryParams.disabled";
 
     /**
+     * Indicates if any input stream is expected and shall be waited while using POST method. It can happen that a delay
+     * appear between the time the request is received and the time the input stream is available. In this case, the
+     * server will wait for the input stream to be available before processing the request.
+     *
+     * @since 3.1.11
+     */
+    public static final String WAIT_FOR_INPUT = "jersey.config.servlet.expect.input";
+
+    /**
+     * Timeout which shall be respected while waiting for the input stream to be available.
+     * The timeout is in milliseconds.
+     *
+     * @since 3.1.11
+     */
+    public static final String WAIT_FOR_INPUT_TIMEOUT = "jersey.config.servlet.input.timeout";
+
+    /**
+     * Default timeout which shall be respected while waiting for the input stream to be available.
+     *
+     * @since 3.1.11
+     */
+    public static final Long WAIT_FOR_INPUT_DEFAULT_TIMEOUT = 100L;
+
+    /**
      * Identifies the object that will be used as a parent {@code HK2 ServiceLocator} in the Jersey
      * {@link WebComponent}.
      * <p></p>
diff --git a/containers/jersey-servlet-core/src/main/java/org/glassfish/jersey/servlet/WebComponent.java b/containers/jersey-servlet-core/src/main/java/org/glassfish/jersey/servlet/WebComponent.java
index f35c720..e3c6344 100644
--- a/containers/jersey-servlet-core/src/main/java/org/glassfish/jersey/servlet/WebComponent.java
+++ b/containers/jersey-servlet-core/src/main/java/org/glassfish/jersey/servlet/WebComponent.java
@@ -17,7 +17,6 @@
 package org.glassfish.jersey.servlet;
 
 import java.io.IOException;
-import java.io.InputStream;
 import java.io.UncheckedIOException;
 import java.lang.reflect.Type;
 import java.net.URI;
@@ -57,7 +56,6 @@
 import jakarta.servlet.http.HttpServletRequest;
 import jakarta.servlet.http.HttpServletResponse;
 
-import org.glassfish.jersey.innate.io.InputStreamWrapper;
 import org.glassfish.jersey.internal.ServiceFinderBinder;
 import org.glassfish.jersey.internal.inject.AbstractBinder;
 import org.glassfish.jersey.internal.inject.InjectionManager;
@@ -425,11 +423,15 @@
             final ResponseWriter responseWriter) throws IOException {
 
         try {
-            requestContext.setEntityStream(new InputStreamWrapper() {
-
+            boolean waitForInputEnable = requestContext.resolveProperty(ServletProperties.WAIT_FOR_INPUT, Boolean.TRUE);
+            long waitForInputTimeOut = requestContext.resolveProperty(ServletProperties.WAIT_FOR_INPUT_TIMEOUT,
+                    ServletProperties.WAIT_FOR_INPUT_DEFAULT_TIMEOUT);
+            requestContext.wrapEntityInputStream(new ServletEntityInputStream(waitForInputEnable,
+                    waitForInputTimeOut) {
                 private ServletInputStream wrappedStream;
+
                 @Override
-                protected InputStream getWrapped() {
+                protected ServletInputStream getWrappedStream() {
                     if (wrappedStream == null) {
                         try {
                             wrappedStream = servletRequest.getInputStream();
diff --git a/core-common/src/main/java/org/glassfish/jersey/innate/io/StreamListener.java b/core-common/src/main/java/org/glassfish/jersey/innate/io/StreamListener.java
new file mode 100644
index 0000000..406c765
--- /dev/null
+++ b/core-common/src/main/java/org/glassfish/jersey/innate/io/StreamListener.java
@@ -0,0 +1,53 @@
+/*
+ * Copyright (c) 2025 Oracle and/or its affiliates. All rights reserved.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License v. 2.0, which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the
+ * Eclipse Public License v. 2.0 are satisfied: GNU General Public License,
+ * version 2 with the GNU Classpath Exception, which is available at
+ * https://www.gnu.org/software/classpath/license.html.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
+ */
+
+package org.glassfish.jersey.innate.io;
+
+import org.glassfish.jersey.message.internal.EntityInputStream;
+
+import java.util.EventListener;
+
+/**
+ * Provides possibility to check whether an input stream for an entity is empty or not.
+ * <p>
+ * Is being used in the {@link EntityInputStream#isEmpty()} check
+ * </p>
+ */
+public interface StreamListener extends EventListener {
+
+    /**
+     * Provides information if the underlying stream is empty
+     *
+     * @return true if the underlying stream is empty
+     */
+    boolean isEmpty();
+
+    /**
+     * Can be used to provide readiness information.
+     * <p>
+     * If the stream is not ready the calling check in the {@link EntityInputStream#isEmpty()} method will validate
+     * the underlying stream as not empty.
+     * </p>
+     * <p>
+     * Throws:
+     * IllegalStateException   - if one of the following conditions is true
+     *              the associated request is neither upgraded nor the async started
+     *              underlying setReadListener is called more than once within the scope of the same request.
+     * </p>
+     * @return true if the underlying stream is ready.
+     */
+    boolean isReady();
+}
diff --git a/core-common/src/main/java/org/glassfish/jersey/innate/io/StreamListenerCouple.java b/core-common/src/main/java/org/glassfish/jersey/innate/io/StreamListenerCouple.java
new file mode 100644
index 0000000..34d141d
--- /dev/null
+++ b/core-common/src/main/java/org/glassfish/jersey/innate/io/StreamListenerCouple.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright (c) 2025 Oracle and/or its affiliates. All rights reserved.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License v. 2.0, which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the
+ * Eclipse Public License v. 2.0 are satisfied: GNU General Public License,
+ * version 2 with the GNU Classpath Exception, which is available at
+ * https://www.gnu.org/software/classpath/license.html.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
+ */
+
+package org.glassfish.jersey.innate.io;
+
+import java.io.InputStream;
+
+/**
+ * Couples stream and its listener (if any).
+ * Could be used in connectors to help determine the stream's emptiness.
+ */
+public interface StreamListenerCouple {
+
+    /**
+     * Provides a listener for the underlying input stream. The listener can reflect a particular state which
+     * helps to determine whether the underlying stream is empty or not.
+     *
+     * @return listener
+     */
+    StreamListener getListener();
+
+    /**
+     * Provides underlying input stream.
+     * @return underlying input stream
+     */
+    InputStream getExternalStream();
+
+}
diff --git a/core-common/src/main/java/org/glassfish/jersey/message/internal/EntityInputStream.java b/core-common/src/main/java/org/glassfish/jersey/message/internal/EntityInputStream.java
index 2610e17..ab5a3ec 100644
--- a/core-common/src/main/java/org/glassfish/jersey/message/internal/EntityInputStream.java
+++ b/core-common/src/main/java/org/glassfish/jersey/message/internal/EntityInputStream.java
@@ -1,5 +1,5 @@
 /*
- * Copyright (c) 2012, 2024 Oracle and/or its affiliates. All rights reserved.
+ * Copyright (c) 2012, 2025 Oracle and/or its affiliates. All rights reserved.
  *
  * This program and the accompanying materials are made available under the
  * terms of the Eclipse Public License v. 2.0, which is available at
@@ -22,6 +22,8 @@
 
 import jakarta.ws.rs.ProcessingException;
 
+import org.glassfish.jersey.innate.io.StreamListener;
+import org.glassfish.jersey.innate.io.StreamListenerCouple;
 import org.glassfish.jersey.innate.io.InputStreamWrapper;
 import org.glassfish.jersey.internal.LocalizationMessages;
 
@@ -37,6 +39,9 @@
 public class EntityInputStream extends InputStreamWrapper {
 
     private InputStream input;
+
+    private StreamListener listener;
+
     private boolean closed = false;
 
     /**
@@ -93,13 +98,12 @@
      */
     @Override
     public void close() throws ProcessingException {
-        final InputStream in = input;
-        if (in == null) {
+        if (input == null) {
             return;
         }
         if (!closed) {
             try {
-                in.close();
+                input.close();
             } catch (IOException ex) {
                 // This e.g. means that the underlying socket stream got closed by other thread somehow...
                 throw new ProcessingException(LocalizationMessages.MESSAGE_CONTENT_INPUT_STREAM_CLOSE_FAILED(), ex);
@@ -119,43 +123,46 @@
      */
     public boolean isEmpty() {
         ensureNotClosed();
-
-        final InputStream in = input;
-        if (in == null) {
+        if (input == null) {
             return true;
         }
 
         try {
             // Try #markSupported first - #available on WLS waits until socked timeout is reached when chunked encoding is used.
-            if (in.markSupported()) {
-                in.mark(1);
-                int i = in.read();
-                in.reset();
+            if (input.markSupported()) {
+                input.mark(1);
+                int i = input.read();
+                input.reset();
                 return i == -1;
             } else {
+                int availableBytes = 0;
                 try {
-                    if (in.available() > 0) {
-                        return false;
-                    }
+                    availableBytes = input.available();
                 } catch (IOException ioe) {
                     // NOOP. Try other approaches as this can fail on WLS.
                 }
 
-                int b = in.read();
-                if (b == -1) {
-                    return true;
+                if (availableBytes > 0) {
+                    return false;
                 }
 
-                PushbackInputStream pbis;
-                if (in instanceof PushbackInputStream) {
-                    pbis = (PushbackInputStream) in;
-                } else {
-                    pbis = new PushbackInputStream(in, 1);
-                    input = pbis;
+                if (listener != null) {
+                    try {
+                        if (!listener.isReady()) {
+                            return false;
+                        }
+                        return listener.isEmpty();
+                    } catch (IllegalStateException ex) {
+                        // NOOP. Listener failed to process the emptiness, the final method to be applied
+                    }
                 }
-                pbis.unread(b);
 
-                return false;
+                final PushbackInputStream in = (input instanceof PushbackInputStream) ? (PushbackInputStream) input
+                       : new PushbackInputStream(input);
+                int i = in.read();
+                in.unread(i);
+                input = in;
+                return i == -1;
             }
         } catch (IOException ex) {
             throw new ProcessingException(ex);
@@ -188,7 +195,7 @@
      * @return wrapped input stream instance.
      */
     public final InputStream getWrappedStream() {
-        return input;
+        return getWrapped();
     }
 
     /**
@@ -201,7 +208,21 @@
     }
 
     @Override
-    protected InputStream getWrapped() {
+    public InputStream getWrapped() {
         return input;
     }
+
+    /**
+     * Decomposes existing {@link EntityInputStream} into this input stream
+     * @param stream instance of the {@link EntityInputStream}
+     */
+    public void wrapExternalStream(StreamListenerCouple stream) {
+        input = new InputStreamWrapper() {
+            @Override
+            public InputStream getWrapped() {
+                return stream.getExternalStream();
+            }
+        };
+        listener = stream.getListener();
+    }
 }
diff --git a/core-common/src/main/java/org/glassfish/jersey/message/internal/InboundMessageContext.java b/core-common/src/main/java/org/glassfish/jersey/message/internal/InboundMessageContext.java
index 46446a4..0d88f13 100644
--- a/core-common/src/main/java/org/glassfish/jersey/message/internal/InboundMessageContext.java
+++ b/core-common/src/main/java/org/glassfish/jersey/message/internal/InboundMessageContext.java
@@ -45,6 +45,7 @@
 
 import javax.xml.transform.Source;
 
+import org.glassfish.jersey.innate.io.StreamListenerCouple;
 import org.glassfish.jersey.innate.io.SafelyClosable;
 import org.glassfish.jersey.internal.LocalizationMessages;
 import org.glassfish.jersey.internal.PropertiesDelegate;
@@ -599,6 +600,16 @@
     }
 
     /**
+     * Provides the whole {@link EntityInputStream} to the request
+     *
+     * @param stream the whole input stream entity
+     */
+    public void wrapEntityInputStream(StreamListenerCouple stream) {
+        this.entityContent.wrapExternalStream(stream);
+        this.entityContent.buffered = false;
+    }
+
+    /**
      * Read entity from a context entity input stream.
      *
      * @param <T>                entity Java object type.
diff --git a/core-server/src/main/java/org/glassfish/jersey/server/ContainerRequest.java b/core-server/src/main/java/org/glassfish/jersey/server/ContainerRequest.java
index 6d9fba2..65b033e 100644
--- a/core-server/src/main/java/org/glassfish/jersey/server/ContainerRequest.java
+++ b/core-server/src/main/java/org/glassfish/jersey/server/ContainerRequest.java
@@ -1,5 +1,5 @@
 /*
- * Copyright (c) 2012, 2023 Oracle and/or its affiliates. All rights reserved.
+ * Copyright (c) 2012, 2025 Oracle and/or its affiliates. All rights reserved.
  *
  * This program and the accompanying materials are made available under the
  * terms of the Eclipse Public License v. 2.0, which is available at
@@ -48,6 +48,7 @@
 import jakarta.ws.rs.ext.WriterInterceptor;
 
 import org.glassfish.jersey.http.HttpHeaders;
+import org.glassfish.jersey.innate.io.StreamListenerCouple;
 import org.glassfish.jersey.internal.PropertiesDelegate;
 import org.glassfish.jersey.internal.guava.Preconditions;
 import org.glassfish.jersey.internal.PropertiesResolver;
@@ -547,6 +548,12 @@
     }
 
     @Override
+    public void wrapEntityInputStream(final StreamListenerCouple input) {
+        Preconditions.checkState(!inResponseProcessingPhase, ERROR_REQUEST_SET_ENTITY_STREAM_IN_RESPONSE_PHASE);
+        super.wrapEntityInputStream(input);
+    }
+
+    @Override
     public Request getRequest() {
         return this;
     }
diff --git a/docs/src/main/docbook/appendix-properties.xml b/docs/src/main/docbook/appendix-properties.xml
index 43b5bed..c4d66aa 100644
--- a/docs/src/main/docbook/appendix-properties.xml
+++ b/docs/src/main/docbook/appendix-properties.xml
@@ -1022,6 +1022,25 @@
                             </para>
                         </entry>
                     </row>
+                    <row>
+                        <entry>&jersey.servlet.ServletProperties.WAIT_FOR_INPUT;</entry>
+                        <entry><literal>jersey.config.servlet.expect.input</literal></entry>
+                        <entry>
+                            <para>
+                                If <literal>true</literal> then Jersey will wait for input when the request is a POST or PUT.
+                            </para>
+                        </entry>
+                    </row>
+                    <row>
+                        <entry>&jersey.servlet.ServletProperties.WAIT_FOR_INPUT_TIMEOUT;</entry>
+                        <entry><literal>jersey.config.servlet.expect.input</literal></entry>
+                        <entry>
+                            <para>
+                                The timeout in milliseconds for waiting for input when the request is a POST or PUT.
+                                Default value is  <literal>100</literal> milliseconds.
+                            </para>
+                        </entry>
+                    </row>
                 </tbody>
             </tgroup>
         </table>
diff --git a/docs/src/main/docbook/jersey.ent b/docs/src/main/docbook/jersey.ent
index 614c765..905b8d1 100644
--- a/docs/src/main/docbook/jersey.ent
+++ b/docs/src/main/docbook/jersey.ent
@@ -726,6 +726,8 @@
 <!ENTITY jersey.servlet.ServletProperties.PROVIDER_WEB_APP "<link xlink:href='&jersey.javadoc.uri.prefix;/servlet/ServletProperties.html#PROVIDER_WEB_APP'>ServletProperties.PROVIDER_WEB_APP</link>">
 <!ENTITY jersey.servlet.ServletProperties.QUERY_PARAMS_AS_FORM_PARAMS_DISABLED "<link xlink:href='&jersey.javadoc.uri.prefix;/servlet/ServletProperties.html#QUERY_PARAMS_AS_FORM_PARAMS_DISABLED'>ServletProperties.QUERY_PARAMS_AS_FORM_PARAMS_DISABLED</link>">
 <!ENTITY jersey.servlet.ServletProperties.SERVICE_LOCATOR "<link xlink:href='&jersey.javadoc.uri.prefix;/servlet/ServletProperties.html#SERVICE_LOCATOR'>ServletProperties.SERVICE_LOCATOR</link>">
+<!ENTITY jersey.servlet.ServletProperties.WAIT_FOR_INPUT "<link xlink:href='&jersey.javadoc.uri.prefix;/servlet/ServletProperties.html#WAIT_FOR_INPUT'>ServletProperties.WAIT_FOR_INPUT</link>">
+<!ENTITY jersey.servlet.ServletProperties.WAIT_FOR_INPUT_TIMEOUT "<link xlink:href='&jersey.javadoc.uri.prefix;/servlet/ServletProperties.html#WAIT_FOR_INPUT_TIMEOUT'>ServletProperties.WAIT_FOR_INPUT_TIMEOUT</link>">
 <!ENTITY jersey.simple.SimpleContainer "<link xlink:href='&jersey.javadoc.uri.prefix;/simple/SimpleContainer.html'>SimpleContainer</link>">
 <!ENTITY jersey.simple.SimpleContainerFactory "<link xlink:href='&jersey.javadoc.uri.prefix;/simple/SimpleContainerFactory.html'>SimpleContainerFactory</link>">
 <!ENTITY jersey.simple.SimpleContainerProvider "<link xlink:href='&jersey.javadoc.uri.prefix;/simple/SimpleContainerProvider.html'>SimpleContainerProvider</link>">
diff --git a/tests/e2e-client/pom.xml b/tests/e2e-client/pom.xml
index 3dce2ff..90426d5 100644
--- a/tests/e2e-client/pom.xml
+++ b/tests/e2e-client/pom.xml
@@ -112,6 +112,11 @@
             <scope>test</scope>
         </dependency>
         <dependency>
+            <groupId>org.glassfish.jersey.media</groupId>
+            <artifactId>jersey-media-json-jackson</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
             <groupId>org.glassfish.jersey.ext</groupId>
             <artifactId>jersey-entity-filtering</artifactId>
             <scope>test</scope>
@@ -240,6 +245,12 @@
                     <groupId>org.glassfish.jersey.test-framework.providers</groupId>
                     <artifactId>jersey-test-framework-provider-jetty</artifactId>
                 </dependency>
+                <dependency>
+                    <groupId>org.eclipse.jetty.ee10</groupId>
+                    <artifactId>jetty-ee10-servlet</artifactId>
+                    <version>${jetty.version}</version>
+                    <scope>test</scope>
+                </dependency>
             </dependencies>
         </profile>
         <profile>
@@ -332,6 +343,7 @@
                                         <testExclude>org/glassfish/jersey/tests/e2e/client/connector/proxy/Proxy*Test.java</testExclude>
                                         <testExclude>org/glassfish/jersey/tests/e2e/client/connector/NoContentLengthTest.java</testExclude>
                                         <testExclude>org/glassfish/jersey/tests/e2e/client/nettyconnector/Expect100ContinueTest.java</testExclude>
+                                        <testExclude>org/glassfish/jersey/tests/e2e/client/ResponseReadEntityStreamTest.java</testExclude>
                                     </testExcludes>
                                 </configuration>
                                 <goals>
diff --git a/tests/e2e-client/src/test/java/org/glassfish/jersey/tests/e2e/client/ResponseReadEntityStreamTest.java b/tests/e2e-client/src/test/java/org/glassfish/jersey/tests/e2e/client/ResponseReadEntityStreamTest.java
new file mode 100644
index 0000000..2633d6b
--- /dev/null
+++ b/tests/e2e-client/src/test/java/org/glassfish/jersey/tests/e2e/client/ResponseReadEntityStreamTest.java
@@ -0,0 +1,203 @@
+/*
+ * Copyright (c) 2025 Oracle and/or its affiliates. All rights reserved.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License v. 2.0, which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the
+ * Eclipse Public License v. 2.0 are satisfied: GNU General Public License,
+ * version 2 with the GNU Classpath Exception, which is available at
+ * https://www.gnu.org/software/classpath/license.html.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
+ */
+
+package org.glassfish.jersey.tests.e2e.client;
+
+import jakarta.inject.Inject;
+import jakarta.servlet.ServletInputStream;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.ws.rs.Consumes;
+import jakarta.ws.rs.POST;
+import jakarta.ws.rs.Path;
+import jakarta.ws.rs.Produces;
+import jakarta.ws.rs.client.Entity;
+import jakarta.ws.rs.client.Invocation;
+import jakarta.ws.rs.core.Application;
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.Response;
+import com.fasterxml.jackson.core.JsonFactory;
+import com.fasterxml.jackson.core.JsonGenerator;
+import org.eclipse.jetty.ee10.servlet.ServletContextHandler;
+import org.eclipse.jetty.ee10.servlet.ServletHolder;
+import org.eclipse.jetty.server.Server;
+import org.eclipse.jetty.server.ServerConnector;
+import org.glassfish.jersey.client.ClientConfig;
+import org.glassfish.jersey.jetty.JettyHttpContainerFactory;
+import org.glassfish.jersey.server.ResourceConfig;
+import org.glassfish.jersey.servlet.ServletContainer;
+import org.glassfish.jersey.test.JerseyTest;
+import org.glassfish.jersey.test.spi.TestContainer;
+import org.glassfish.jersey.test.spi.TestContainerException;
+import org.glassfish.jersey.test.spi.TestContainerFactory;
+import org.junit.jupiter.api.Test;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileWriter;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URI;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * Tests the presence of the first byte in very large stream in each of 1000 requests
+ * Particularly for {@link org.glassfish.jersey.message.internal.EntityInputStream#isEmpty} method
+ * <p>
+ * Also introduces mixture of the Jetty server and servlet holder in which the error was reproduced.
+ */
+public class ResponseReadEntityStreamTest extends JerseyTest {
+
+    @Override
+    protected TestContainerFactory getTestContainerFactory() throws TestContainerException {
+        return (baseUri, deploymentContext) -> {
+            final Server server = JettyHttpContainerFactory.createServer(baseUri, false);
+            final ServerConnector connector = new ServerConnector(server);
+            connector.setPort(9001);
+            server.addConnector(connector);
+
+            final ResourceConfig resConfig = new ResourceConfig(Analyze.class);
+
+            final ServletContainer jerseyServletContainer = new ServletContainer(resConfig);
+            final ServletHolder jettyServletHolder = new ServletHolder(jerseyServletContainer);
+
+            final ServletContextHandler context = new ServletContextHandler(ServletContextHandler.NO_SESSIONS);
+            context.setContextPath("/");
+
+            context.addServlet(jettyServletHolder, "/api/*");
+
+            server.setHandler(context);
+            return new TestContainer() {
+                @Override
+                public ClientConfig getClientConfig() {
+                    return new ClientConfig();
+                }
+
+                @Override
+                public URI getBaseUri() {
+                    return baseUri;
+                }
+
+                @Override
+                public void start() {
+                    try {
+                        server.start();
+                    } catch (Exception e) {
+                        throw new RuntimeException(e);
+                    }
+                }
+
+                @Override
+                public void stop() {
+                    try {
+                        server.stop();
+                    } catch (Exception e) {
+                        throw new RuntimeException(e);
+                    }
+                }
+            };
+        };
+    }
+
+    @Override
+    protected Application configure() {
+        return new ResourceConfig(ResponseReadEntityStreamTest.Analyze.class);
+    }
+
+    private static void generateJson(final String filePath, int recordCount) throws IOException {
+
+        try (final JsonGenerator generator = new JsonFactory().createGenerator(new FileWriter(filePath))) {
+            generator.writeStartArray();
+
+            for (int i = 0; i < recordCount; i++) {
+                generator.writeStartObject();
+                generator.writeNumberField("id", i);
+                generator.writeStringField("name", "User" + i);
+                // Add more fields as needed
+                generator.writeEndObject();
+
+                if (i % 10000 == 0) {
+                    generator.flush();
+                }
+            }
+
+            generator.writeEndArray();
+        }
+    }
+
+    @Test
+    public void readEntityTest() throws IOException {
+        final Invocation.Builder requestBuilder = target("/api/v1/analyze").request();
+        //iterate 1000 requests to be sure the first byte is not lost
+        final String fileName = "bigFile.json";
+        final String path = "target/" + fileName;
+
+        final java.nio.file.Path pathResource = Paths.get(path);
+
+        final java.nio.file.Path realFilePath = Files.createFile(pathResource.toAbsolutePath());
+
+        try {
+
+            generateJson(realFilePath.toString(), 1000000); // 33Mb real file size
+
+            final File bigFile = realFilePath.toFile();
+
+            for (int i = 1; i < 1000; i++) {
+                try (final FileInputStream stream = new FileInputStream(bigFile)) {
+                    final Response response = requestBuilder.post(Entity.entity(stream,
+                            MediaType.APPLICATION_FORM_URLENCODED_TYPE));
+                    assertEquals(200, response.getStatus());
+                    response.close();
+                }
+            }
+        } finally {
+            Files.deleteIfExists(pathResource);
+        }
+
+    }
+
+    @Path("/v1")
+    @Produces(MediaType.APPLICATION_JSON)
+    public static class Analyze {
+
+        @Inject
+        private HttpServletRequest request;
+
+        @POST
+        @Path("/analyze")
+        @Produces(MediaType.APPLICATION_JSON)
+        @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
+        public Response analyze() {
+
+            try (final ServletInputStream inputStream = request.getInputStream()) {
+                final byte[] content = inputStream.readAllBytes();
+
+                if (content[0] != 91 /* character [ */) { // https://www.ascii-code.com/91
+                    throw new Exception("Oops");
+                }
+
+                return Response.ok("{\"status\":\"OK\"}").build();
+
+            } catch (Exception e) {
+                return Response.serverError().build();
+            }
+
+        }
+    }
+
+}