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();
+ }
+
+ }
+ }
+
+}