diff --git a/documentation/jetty/modules/operations-guide/pages/modules/standard.adoc b/documentation/jetty/modules/operations-guide/pages/modules/standard.adoc index 730fb220e530..849f839dddbb 100644 --- a/documentation/jetty/modules/operations-guide/pages/modules/standard.adoc +++ b/documentation/jetty/modules/operations-guide/pages/modules/standard.adoc @@ -129,6 +129,21 @@ The module properties are: include::{jetty-home}/modules/debuglog.mod[tags=documentation] ---- +[[eager-content]] +== Module `eager-content` + +The `eager-content` module installs the `org.eclipse.jetty.server.handler.EagerContentHandler` at the root of the `Handler` tree. + +The `EagerContentHandler` can eagerly load request content, asynchronously, before calling the next `Handler`. +For more information see xref:programming-guide:server/http.adoc#handler-use-eager[this section]. + +`EagerContentHandler` can eagerly load content for form uploads, multipart uploads and any request content, and you can configure it with different properties for these three cases. + +The module properties are: + +---- +include::{jetty-home}/modules/eager-content.mod[tags=documentation] +---- [[eeN-deploy]] == Module `{ee-all}-deploy` diff --git a/documentation/jetty/modules/programming-guide/pages/server/http.adoc b/documentation/jetty/modules/programming-guide/pages/server/http.adoc index 19319cd45fc5..8683a74e0d55 100644 --- a/documentation/jetty/modules/programming-guide/pages/server/http.adoc +++ b/documentation/jetty/modules/programming-guide/pages/server/http.adoc @@ -1198,6 +1198,44 @@ In the example above, `ContextHandlerCollection` will try to match a request to NOTE: `DefaultHandler` just sends a nicer HTTP `404` response in case of wrong requests from clients. Jetty will send an HTTP `404` response anyway if `DefaultHandler` has not been set. +[[handler-use-eager]] +==== EagerContentHandler + +`EagerContentHandler` reads eagerly the HTTP request content, and invokes the next `Handler` in the `Handler` tree when the request content has been read. + +`EagerContentHandler` should be installed when web applications use blocking I/O to read the request content, which is the typical case for Servlet or RESTful (JAX-RS) web applications. + +Because the request content is read eagerly and asynchronously, the web application will never (or rarely) block while reading the request content. +In this way, the application obtains the benefits of asynchronous I/O without forcing web application developers to use more complicated asynchronous I/O APIs. + +The `Handler` tree structure looks like the following: + +[,screen] +---- +Server +└── (GzipHandler) // optional + └── EagerContentHandler + └── ContextHandler /app + └── AppHandler +---- + +`EagerContentHandler` should be installed in the `Handler` tree _after_ other ``Handler``s that may modify or transform the request content, like for example the `GzipHandler`. + +`EagerContentHandler` eagerly reads request content in the following cases: + +* Form request content. +* MultiPart request content. +* Any other type of request content. + +For Form request content, `EagerContentHandler` reads the whole request content, parses it into a `Fields` object, and then invokes the next `Handler`. +This allows web applications that use blocking API calls such as `HttpServletRequest.getParameterMap()` to avoid blocking, since they can directly use the already created `Fields` object. + +Similarly, for MultiPart request content, `EagerContentHandler` reads the whole request content, parses it into `MultiPartFormData.Parts`, and then invokes the next `Handler`. +This allows web applications that use blocking API calls such as `HttpServletRequest.getParts()` to avoid blocking, since the can directly use the already created `MultiPartFormData.Parts` object. + +For other types of request content, `EagerContentHandler` reads and retains request content bytes up to a configurable amount, and then invokes the next `Handler`, without any further processing of the request content bytes. +This allows web applications that use blocking API calls such as `HttpServletRequest.getInputStream()` to avoid blocking in most cases (if the request is smaller than what has been configured in `EagerContentHandler`). + [[handler-use-servlet]] === Servlet API Handlers diff --git a/jetty-core/jetty-client/src/main/java/org/eclipse/jetty/client/FormRequestContent.java b/jetty-core/jetty-client/src/main/java/org/eclipse/jetty/client/FormRequestContent.java index ee930a737cb3..4dac58869b15 100644 --- a/jetty-core/jetty-client/src/main/java/org/eclipse/jetty/client/FormRequestContent.java +++ b/jetty-core/jetty-client/src/main/java/org/eclipse/jetty/client/FormRequestContent.java @@ -17,6 +17,7 @@ import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; +import org.eclipse.jetty.http.MimeTypes; import org.eclipse.jetty.util.Fields; /** @@ -32,7 +33,10 @@ public FormRequestContent(Fields fields) public FormRequestContent(Fields fields, Charset charset) { - super("application/x-www-form-urlencoded", convert(fields, charset), charset); + super(charset == StandardCharsets.UTF_8 + ? MimeTypes.Type.FORM_ENCODED_UTF_8.asString() + : MimeTypes.Type.FORM_ENCODED.asString() + ";charset=" + charset.name().toLowerCase(), + convert(fields, charset), charset); } public static String convert(Fields fields) @@ -48,7 +52,7 @@ public static String convert(Fields fields, Charset charset) { for (String value : field.getValues()) { - if (builder.length() > 0) + if (!builder.isEmpty()) builder.append("&"); builder.append(encode(field.getName(), charset)).append("=").append(encode(value, charset)); } diff --git a/jetty-core/jetty-client/src/test/java/org/eclipse/jetty/client/util/TypedContentProviderTest.java b/jetty-core/jetty-client/src/test/java/org/eclipse/jetty/client/util/TypedContentProviderTest.java index 1057d69585bf..71b13ffaa9c0 100644 --- a/jetty-core/jetty-client/src/test/java/org/eclipse/jetty/client/util/TypedContentProviderTest.java +++ b/jetty-core/jetty-client/src/test/java/org/eclipse/jetty/client/util/TypedContentProviderTest.java @@ -35,6 +35,7 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.equalToIgnoringCase; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -57,14 +58,12 @@ public void testFormContentProvider(Scenario scenario) throws Exception protected void service(Request request, Response response) { assertEquals("POST", request.getMethod()); - assertEquals(MimeTypes.Type.FORM_ENCODED.asString(), request.getHeaders().get(HttpHeader.CONTENT_TYPE)); - FormFields.from(request).whenComplete((fields, failure) -> - { - assertEquals(value1, fields.get(name1).getValue()); - List values = fields.get(name2).getValues(); - assertEquals(2, values.size()); - assertThat(values, containsInAnyOrder(value2, value3)); - }); + assertThat(request.getHeaders().get(HttpHeader.CONTENT_TYPE), equalToIgnoringCase(MimeTypes.Type.FORM_ENCODED_UTF_8.asString())); + Fields fields = FormFields.getFields(request); + assertEquals(value1, fields.get(name1).getValue()); + List values = fields.get(name2).getValues(); + assertEquals(2, values.size()); + assertThat(values, containsInAnyOrder(value2, value3)); } }); @@ -89,7 +88,7 @@ public void testFormContentProviderWithDifferentContentType(Scenario scenario) t fields.put(name1, value1); fields.add(name2, value2); final String content = FormRequestContent.convert(fields); - final String contentType = "text/plain;charset=UTF-8"; + final String contentType = "text/plain;charset=utf-8"; start(scenario, new EmptyServerHandler() { diff --git a/jetty-core/jetty-deploy/src/main/java/org/eclipse/jetty/deploy/providers/ContextProvider.java b/jetty-core/jetty-deploy/src/main/java/org/eclipse/jetty/deploy/providers/ContextProvider.java index db604b422eed..dc2c0aecbe55 100644 --- a/jetty-core/jetty-deploy/src/main/java/org/eclipse/jetty/deploy/providers/ContextProvider.java +++ b/jetty-core/jetty-deploy/src/main/java/org/eclipse/jetty/deploy/providers/ContextProvider.java @@ -24,7 +24,6 @@ import java.nio.file.Paths; import java.util.ArrayList; import java.util.Arrays; -import java.util.Comparator; import java.util.HashMap; import java.util.List; import java.util.Locale; @@ -305,10 +304,7 @@ else if (Supplier.class.isAssignableFrom(context.getClass())) initializeContextPath(contextHandler, path); if (Files.isDirectory(path)) - { contextHandler.setBaseResource(ResourceFactory.of(this).newResource(path)); - System.err.println("SET BASE RESOURCE to " + path); - } //TODO think of better way of doing this //pass through properties as attributes directly diff --git a/jetty-core/jetty-http/src/main/java/org/eclipse/jetty/http/MimeTypes.java b/jetty-core/jetty-http/src/main/java/org/eclipse/jetty/http/MimeTypes.java index 119931fa2572..f35766930d95 100644 --- a/jetty-core/jetty-http/src/main/java/org/eclipse/jetty/http/MimeTypes.java +++ b/jetty-core/jetty-http/src/main/java/org/eclipse/jetty/http/MimeTypes.java @@ -61,7 +61,11 @@ public class MimeTypes public enum Type { FORM_ENCODED("application/x-www-form-urlencoded"), + FORM_ENCODED_UTF_8("application/x-www-form-urlencoded;charset=utf-8", FORM_ENCODED), + FORM_ENCODED_8859_1("application/x-www-form-urlencoded;charset=iso-8859-1", FORM_ENCODED), + MESSAGE_HTTP("message/http"), + MULTIPART_BYTERANGES("multipart/byteranges"), MULTIPART_FORM_DATA("multipart/form-data"), @@ -77,6 +81,10 @@ public HttpField getContentTypeField(Charset charset) return super.getContentTypeField(charset); } }, + + TEXT_HTML_8859_1("text/html;charset=iso-8859-1", TEXT_HTML), + TEXT_HTML_UTF_8("text/html;charset=utf-8", TEXT_HTML), + TEXT_PLAIN("text/plain") { @Override @@ -89,6 +97,9 @@ public HttpField getContentTypeField(Charset charset) return super.getContentTypeField(charset); } }, + TEXT_PLAIN_8859_1("text/plain;charset=iso-8859-1", TEXT_PLAIN), + TEXT_PLAIN_UTF_8("text/plain;charset=utf-8", TEXT_PLAIN), + TEXT_XML("text/xml") { @Override @@ -101,21 +112,14 @@ public HttpField getContentTypeField(Charset charset) return super.getContentTypeField(charset); } }, - TEXT_JSON("text/json", StandardCharsets.UTF_8), - APPLICATION_JSON("application/json", StandardCharsets.UTF_8), - - TEXT_HTML_8859_1("text/html;charset=iso-8859-1", TEXT_HTML), - TEXT_HTML_UTF_8("text/html;charset=utf-8", TEXT_HTML), - - TEXT_PLAIN_8859_1("text/plain;charset=iso-8859-1", TEXT_PLAIN), - TEXT_PLAIN_UTF_8("text/plain;charset=utf-8", TEXT_PLAIN), - TEXT_XML_8859_1("text/xml;charset=iso-8859-1", TEXT_XML), TEXT_XML_UTF_8("text/xml;charset=utf-8", TEXT_XML), + TEXT_JSON("text/json", StandardCharsets.UTF_8), TEXT_JSON_8859_1("text/json;charset=iso-8859-1", TEXT_JSON), TEXT_JSON_UTF_8("text/json;charset=utf-8", TEXT_JSON), + APPLICATION_JSON("application/json", StandardCharsets.UTF_8), APPLICATION_JSON_8859_1("application/json;charset=iso-8859-1", APPLICATION_JSON), APPLICATION_JSON_UTF_8("application/json;charset=utf-8", APPLICATION_JSON); @@ -691,7 +695,25 @@ public static MimeTypes.Type getMimeTypeFromContentType(HttpField field) if (field instanceof MimeTypes.ContentTypeField contentTypeField) return contentTypeField.getMimeType(); - return MimeTypes.CACHE.get(field.getValue()); + String contentType = field.getValue(); + int semicolon = contentType.indexOf(';'); + if (semicolon >= 0) + contentType = contentType.substring(0, semicolon).trim(); + + return MimeTypes.CACHE.get(contentType); + } + + public static String getMimeTypeAsStringFromContentType(HttpField field) + { + if (field == null) + return null; + + assert field.getHeader() == HttpHeader.CONTENT_TYPE; + + if (field instanceof MimeTypes.ContentTypeField contentTypeField) + return contentTypeField.getMimeType().asString(); + + return getBase(field.getValue()); } /** diff --git a/jetty-core/jetty-http/src/main/java/org/eclipse/jetty/http/MultiPartConfig.java b/jetty-core/jetty-http/src/main/java/org/eclipse/jetty/http/MultiPartConfig.java index 608674a69bbf..57d99321e0d9 100644 --- a/jetty-core/jetty-http/src/main/java/org/eclipse/jetty/http/MultiPartConfig.java +++ b/jetty-core/jetty-http/src/main/java/org/eclipse/jetty/http/MultiPartConfig.java @@ -18,6 +18,7 @@ import org.eclipse.jetty.io.Content; import org.eclipse.jetty.util.Attributes; import org.eclipse.jetty.util.Promise; +import org.eclipse.jetty.util.resource.ResourceFactory; import static org.eclipse.jetty.http.ComplianceViolation.Listener.NOOP; @@ -51,6 +52,15 @@ public Builder() { } + /** + * @param location the directory where parts will be saved as files. + */ + public Builder location(String location) + { + location(ResourceFactory.root().newResource(location).getPath()); + return this; + } + /** * @param location the directory where parts will be saved as files. */ @@ -70,7 +80,7 @@ public Builder maxParts(int maxParts) } /** - * @return the maximum size in bytes of the whole multipart content, or -1 for unlimited. + * @param maxSize the maximum size in bytes of the whole multipart content, or -1 for unlimited. */ public Builder maxSize(long maxSize) { @@ -79,7 +89,7 @@ public Builder maxSize(long maxSize) } /** - * @return the maximum part size in bytes, or -1 for unlimited. + * @param maxPartSize the maximum part size in bytes, or -1 for unlimited. */ public Builder maxPartSize(long maxPartSize) { diff --git a/jetty-core/jetty-http2/jetty-http2-client/src/main/java/org/eclipse/jetty/http2/client/HTTP2ClientConnectionFactory.java b/jetty-core/jetty-http2/jetty-http2-client/src/main/java/org/eclipse/jetty/http2/client/HTTP2ClientConnectionFactory.java index 2dcb0fed79bb..289d8bce0245 100644 --- a/jetty-core/jetty-http2/jetty-http2-client/src/main/java/org/eclipse/jetty/http2/client/HTTP2ClientConnectionFactory.java +++ b/jetty-core/jetty-http2/jetty-http2-client/src/main/java/org/eclipse/jetty/http2/client/HTTP2ClientConnectionFactory.java @@ -84,7 +84,7 @@ private static class HTTP2ClientConnection extends HTTP2Connection implements Ca private HTTP2ClientConnection(HTTP2Client client, EndPoint endpoint, HTTP2ClientSession session, Promise sessionPromise, Session.Listener listener) { - super(client.getByteBufferPool(), client.getExecutor(), endpoint, session, client.getInputBufferSize()); + super(client.getByteBufferPool(), client.getExecutor(), endpoint, session, client.getInputBufferSize(), -1); this.client = client; this.promise = sessionPromise; this.listener = listener; diff --git a/jetty-core/jetty-http2/jetty-http2-common/src/main/java/org/eclipse/jetty/http2/HTTP2Connection.java b/jetty-core/jetty-http2/jetty-http2-common/src/main/java/org/eclipse/jetty/http2/HTTP2Connection.java index 43b50655268c..3d6f03dca956 100644 --- a/jetty-core/jetty-http2/jetty-http2-common/src/main/java/org/eclipse/jetty/http2/HTTP2Connection.java +++ b/jetty-core/jetty-http2/jetty-http2-common/src/main/java/org/eclipse/jetty/http2/HTTP2Connection.java @@ -20,6 +20,7 @@ import java.util.concurrent.Executor; import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; import org.eclipse.jetty.http2.api.Stream; import org.eclipse.jetty.http2.frames.DataFrame; @@ -58,16 +59,23 @@ public class HTTP2Connection extends AbstractConnection implements Parser.Listen private final ByteBufferPool bufferPool; private final HTTP2Session session; private final int bufferSize; + private final int minBufferSpace; private final ExecutionStrategy strategy; private boolean useInputDirectByteBuffers; private boolean useOutputDirectByteBuffers; protected HTTP2Connection(ByteBufferPool bufferPool, Executor executor, EndPoint endPoint, HTTP2Session session, int bufferSize) + { + this(bufferPool, executor, endPoint, session, bufferSize, -1); + } + + protected HTTP2Connection(ByteBufferPool bufferPool, Executor executor, EndPoint endPoint, HTTP2Session session, int bufferSize, int minBufferSpace) { super(endPoint, executor); this.bufferPool = bufferPool; this.session = session; this.bufferSize = bufferSize; + this.minBufferSpace = minBufferSpace < 0 ? Math.min(1500, bufferSize) : minBufferSpace; this.strategy = new AdaptiveExecutionStrategy(producer, executor); LifeCycle.start(strategy); } @@ -146,6 +154,7 @@ public void onClose(Throwable cause) LOG.debug("HTTP2 Close {} ", this); super.onClose(cause); LifeCycle.stop(strategy); + producer.stop(); } @Override @@ -156,12 +165,20 @@ public void onFillable() produce(); } - private int fill(EndPoint endPoint, ByteBuffer buffer) + private int fill(EndPoint endPoint, ByteBuffer buffer, boolean compact) { + int padding = 0; try { if (endPoint.isInputShutdown()) return -1; + + if (!compact) + { + // Add padding content to avoid compaction + padding = buffer.limit(); + buffer.position(0); + } return endPoint.fill(buffer); } catch (IOException x) @@ -170,6 +187,11 @@ private int fill(EndPoint endPoint, ByteBuffer buffer) LOG.debug("Could not read from {}", endPoint, x); return -1; } + finally + { + if (!compact && padding > 0) + buffer.position(padding); + } } @Override @@ -302,16 +324,19 @@ public void onConnectionFailure(int error, String reason) protected class HTTP2Producer implements ExecutionStrategy.Producer { + private static final RetainableByteBuffer.Mutable STOPPED = new RetainableByteBuffer.NonRetainableByteBuffer(BufferUtil.EMPTY_BUFFER); private final Callback fillableCallback = new FillableCallback(); + private final AtomicReference heldBuffer = new AtomicReference<>(); private RetainableByteBuffer.Mutable networkBuffer; private boolean shutdown; private boolean failed; private void setInputBuffer(ByteBuffer byteBuffer) { - acquireNetworkBuffer(); + RetainableByteBuffer.Mutable networkBuffer = acquireBuffer(); if (!networkBuffer.append(byteBuffer)) - LOG.warn("overflow"); + throw new IllegalStateException("overflow"); + holdBuffer(networkBuffer); } @Override @@ -327,13 +352,14 @@ public Runnable produce() return null; boolean interested = false; - acquireNetworkBuffer(); + networkBuffer = acquireBuffer(); try { boolean parse = networkBuffer.hasRemaining(); while (true) { + boolean compact = true; if (parse) { while (networkBuffer.hasRemaining()) @@ -348,17 +374,30 @@ public Runnable produce() LOG.debug("Dequeued new task {}", task); if (task != null) return task; + } - // If more references than 1 (ie not just us), don't refill into buffer and risk compaction. - if (networkBuffer.isRetained()) - reacquireNetworkBuffer(); + // If the application has retained the content chunks then we must not overwrite content. + if (networkBuffer.isRetained()) + { + // If there is sufficient space available, we can top up the buffer rather than allocate a new one + if (minBufferSpace > 0 && BufferUtil.space(networkBuffer.getByteBuffer()) >= minBufferSpace) + { + // do not compact the buffer + compact = false; + } + else + { + // otherwise reacquire the buffer and fill into the new buffer. + if (LOG.isDebugEnabled()) + LOG.debug("Released retained {}", networkBuffer); + networkBuffer.release(); + networkBuffer = acquireBuffer(); + } } - // Here we know that this.networkBuffer is not retained by - // application code: either it has been released, or it's a new one. - int filled = fill(getEndPoint(), networkBuffer.getByteBuffer()); + int filled = fill(getEndPoint(), networkBuffer.getByteBuffer(), compact); if (LOG.isDebugEnabled()) - LOG.debug("Filled {} bytes in {}", filled, networkBuffer); + LOG.debug("Filled {} bytes compacted {} in {}", filled, compact, networkBuffer); if (filled > 0) { @@ -381,50 +420,63 @@ else if (filled == 0) } finally { - releaseNetworkBuffer(); + if (networkBuffer.isRetained() && !shutdown) + { + holdBuffer(networkBuffer); + } + else + { + if (LOG.isDebugEnabled()) + LOG.debug("Released after process {}", networkBuffer); + networkBuffer.release(); + } + networkBuffer = null; if (interested) getEndPoint().fillInterested(fillableCallback); } } - private void acquireNetworkBuffer() + private RetainableByteBuffer.Mutable acquireBuffer() { - if (networkBuffer == null) - { - networkBuffer = bufferPool.acquire(bufferSize, isUseInputDirectByteBuffers()).asMutable(); - if (LOG.isDebugEnabled()) - LOG.debug("Acquired {}", networkBuffer); - } + RetainableByteBuffer.Mutable buffer = heldBuffer.getAndSet(null); + if (buffer == null) + buffer = bufferPool.acquire(bufferSize, isUseInputDirectByteBuffers()).asMutable(); + if (LOG.isDebugEnabled()) + LOG.debug("Acquired {}", buffer); + return buffer; } - private void reacquireNetworkBuffer() + private void holdBuffer(RetainableByteBuffer.Mutable buffer) { - RetainableByteBuffer.Mutable currentBuffer = networkBuffer; - if (currentBuffer == null) - throw new IllegalStateException(); - - if (currentBuffer.hasRemaining()) - throw new IllegalStateException(); - - currentBuffer.release(); - networkBuffer = bufferPool.acquire(bufferSize, isUseInputDirectByteBuffers()); - if (LOG.isDebugEnabled()) - LOG.debug("Reacquired {}<-{}", currentBuffer, networkBuffer); + if (heldBuffer.compareAndSet(null, buffer)) + { + if (LOG.isDebugEnabled()) + LOG.debug("Held {}", buffer); + } + else + { + if (heldBuffer.get() == STOPPED) + { + if (LOG.isDebugEnabled()) + LOG.debug("Released instead of holding {}", buffer); + buffer.release(); + } + else + { + throw new IllegalStateException("Buffer already saved"); + } + } } - private void releaseNetworkBuffer() + private void stop() { - RetainableByteBuffer.Mutable currentBuffer = networkBuffer; - if (currentBuffer == null) - throw new IllegalStateException(); - - if (currentBuffer.hasRemaining() && !shutdown && !failed) - throw new IllegalStateException(); - - currentBuffer.release(); - networkBuffer = null; - if (LOG.isDebugEnabled()) - LOG.debug("Released {}", currentBuffer); + RetainableByteBuffer.Mutable buffer = heldBuffer.getAndSet(STOPPED); + if (buffer != null) + { + if (LOG.isDebugEnabled()) + LOG.debug("Released in stop {}", buffer); + buffer.release(); + } } @Override diff --git a/jetty-core/jetty-http2/jetty-http2-server/src/main/java/org/eclipse/jetty/http2/server/internal/HTTP2ServerConnection.java b/jetty-core/jetty-http2/jetty-http2-server/src/main/java/org/eclipse/jetty/http2/server/internal/HTTP2ServerConnection.java index 8ca9b604b090..fb1ef25d20e9 100644 --- a/jetty-core/jetty-http2/jetty-http2-server/src/main/java/org/eclipse/jetty/http2/server/internal/HTTP2ServerConnection.java +++ b/jetty-core/jetty-http2/jetty-http2-server/src/main/java/org/eclipse/jetty/http2/server/internal/HTTP2ServerConnection.java @@ -77,7 +77,7 @@ public class HTTP2ServerConnection extends HTTP2Connection implements Connection public HTTP2ServerConnection(Connector connector, EndPoint endPoint, HttpConfiguration httpConfig, HTTP2ServerSession session, ServerSessionListener listener) { - super(connector.getByteBufferPool(), connector.getExecutor(), endPoint, session, httpConfig.getInputBufferSize()); + super(connector.getByteBufferPool(), connector.getExecutor(), endPoint, session, httpConfig.getInputBufferSize(), httpConfig.getMinInputBufferSpace()); this.connector = connector; this.listener = listener; this.httpConfig = httpConfig; diff --git a/jetty-core/jetty-http2/jetty-http2-tests/src/test/java/org/eclipse/jetty/http2/tests/AbstractTest.java b/jetty-core/jetty-http2/jetty-http2-tests/src/test/java/org/eclipse/jetty/http2/tests/AbstractTest.java index 9a5f944f14da..36637402bc5e 100644 --- a/jetty-core/jetty-http2/jetty-http2-tests/src/test/java/org/eclipse/jetty/http2/tests/AbstractTest.java +++ b/jetty-core/jetty-http2/jetty-http2-tests/src/test/java/org/eclipse/jetty/http2/tests/AbstractTest.java @@ -143,6 +143,8 @@ protected MetaData.Request newRequest(String method, String path, HttpFields fie @AfterEach public void dispose() throws Exception { + // Stop the client so that all connections are closed and any saved buffers are released + LifeCycle.stop(httpClient); try { if (serverBufferPool != null) @@ -152,7 +154,6 @@ public void dispose() throws Exception } finally { - LifeCycle.stop(httpClient); LifeCycle.stop(server); } } diff --git a/jetty-core/jetty-http3/jetty-http3-common/src/main/java/org/eclipse/jetty/http3/HTTP3StreamConnection.java b/jetty-core/jetty-http3/jetty-http3-common/src/main/java/org/eclipse/jetty/http3/HTTP3StreamConnection.java index cde4fed0c315..8e300a1bee37 100644 --- a/jetty-core/jetty-http3/jetty-http3-common/src/main/java/org/eclipse/jetty/http3/HTTP3StreamConnection.java +++ b/jetty-core/jetty-http3/jetty-http3-common/src/main/java/org/eclipse/jetty/http3/HTTP3StreamConnection.java @@ -30,6 +30,7 @@ import org.eclipse.jetty.io.ByteBufferPool; import org.eclipse.jetty.io.RetainableByteBuffer; import org.eclipse.jetty.quic.common.QuicStreamEndPoint; +import org.eclipse.jetty.util.BufferUtil; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -41,6 +42,7 @@ public abstract class HTTP3StreamConnection extends AbstractConnection private final AtomicReference action = new AtomicReference<>(); private final ByteBufferPool bufferPool; + private final int minInputBufferSpace; private final MessageParser parser; private boolean useInputDirectByteBuffers = true; private HTTP3Stream stream; @@ -48,11 +50,17 @@ public abstract class HTTP3StreamConnection extends AbstractConnection private boolean remotelyClosed; public HTTP3StreamConnection(QuicStreamEndPoint endPoint, Executor executor, ByteBufferPool bufferPool, MessageParser parser) + { + this(endPoint, executor, bufferPool, parser, -1); + } + + public HTTP3StreamConnection(QuicStreamEndPoint endPoint, Executor executor, ByteBufferPool bufferPool, MessageParser parser, int minInputBufferSpace) { super(endPoint, executor); this.bufferPool = bufferPool; this.parser = parser; parser.init(MessageListener::new); + this.minInputBufferSpace = minInputBufferSpace < 0 ? 1500 : minInputBufferSpace; } public void onFailure(Throwable failure) @@ -90,6 +98,13 @@ public void onOpen() fillInterested(); } + @Override + public void onClose(Throwable cause) + { + super.onClose(cause); + tryReleaseInputBuffer(true); + } + @Override protected boolean onReadTimeout(TimeoutException timeout) { @@ -262,6 +277,9 @@ private void tryReleaseInputBuffer(boolean force) { if (inputBuffer != null) { + if (inputBuffer.isRetained() && !force) + return; + if (inputBuffer.hasRemaining() && force) inputBuffer.clear(); if (inputBuffer.isEmpty()) @@ -290,17 +308,27 @@ private MessageParser.Result parseAndFill(boolean setFillInterest) throws IOExce if (result != MessageParser.Result.NO_FRAME) return result; + boolean compact = true; if (inputBuffer.isRetained()) { - inputBuffer.release(); - RetainableByteBuffer newBuffer = bufferPool.acquire(getInputBufferSize(), isUseInputDirectByteBuffers()); - if (LOG.isDebugEnabled()) - LOG.debug("reacquired {} for retained {}", newBuffer, inputBuffer); - inputBuffer = newBuffer; - byteBuffer = inputBuffer.getByteBuffer(); + // If there is sufficient space available, we can top up the buffer rather than allocate a new one + if (minInputBufferSpace > 0 && BufferUtil.space(inputBuffer.getByteBuffer()) >= minInputBufferSpace) + { + // do not compact the buffer + compact = false; + } + else + { + inputBuffer.release(); + RetainableByteBuffer newBuffer = bufferPool.acquire(getInputBufferSize(), isUseInputDirectByteBuffers()); + if (LOG.isDebugEnabled()) + LOG.debug("reacquired {} for retained {}", newBuffer, inputBuffer); + inputBuffer = newBuffer; + byteBuffer = inputBuffer.getByteBuffer(); + } } - int filled = fill(byteBuffer); + int filled = fill(byteBuffer, compact); if (LOG.isDebugEnabled()) LOG.debug("filled {} on {} with buffer {}", filled, this, inputBuffer); @@ -335,9 +363,24 @@ private MessageParser.Result parseAndFill(boolean setFillInterest) throws IOExce } } - private int fill(ByteBuffer byteBuffer) throws IOException + private int fill(ByteBuffer buffer, boolean compact) throws IOException { - return getEndPoint().fill(byteBuffer); + int padding = 0; + try + { + if (!compact) + { + // Add padding content to avoid compaction + padding = buffer.limit(); + buffer.position(0); + } + return getEndPoint().fill(buffer); + } + finally + { + if (!compact && padding > 0) + buffer.position(padding); + } } private void processHeaders(HeadersFrame frame, boolean wasBlocked, Runnable delegate) diff --git a/jetty-core/jetty-http3/jetty-http3-server/src/main/java/org/eclipse/jetty/http3/server/internal/ServerHTTP3StreamConnection.java b/jetty-core/jetty-http3/jetty-http3-server/src/main/java/org/eclipse/jetty/http3/server/internal/ServerHTTP3StreamConnection.java index 215f95f3e527..41c8431dfba9 100644 --- a/jetty-core/jetty-http3/jetty-http3-server/src/main/java/org/eclipse/jetty/http3/server/internal/ServerHTTP3StreamConnection.java +++ b/jetty-core/jetty-http3/jetty-http3-server/src/main/java/org/eclipse/jetty/http3/server/internal/ServerHTTP3StreamConnection.java @@ -43,7 +43,7 @@ public class ServerHTTP3StreamConnection extends HTTP3StreamConnection public ServerHTTP3StreamConnection(Connector connector, HttpConfiguration httpConfiguration, QuicStreamEndPoint endPoint, ServerHTTP3Session session, MessageParser parser) { - super(endPoint, connector.getExecutor(), connector.getByteBufferPool(), parser); + super(endPoint, connector.getExecutor(), connector.getByteBufferPool(), parser, httpConfiguration.getMinInputBufferSpace()); this.connector = connector; this.httpConfiguration = httpConfiguration; this.session = session; diff --git a/jetty-core/jetty-io/src/main/java/org/eclipse/jetty/io/AbstractConnection.java b/jetty-core/jetty-io/src/main/java/org/eclipse/jetty/io/AbstractConnection.java index be55b565886a..df299d5e349b 100644 --- a/jetty-core/jetty-io/src/main/java/org/eclipse/jetty/io/AbstractConnection.java +++ b/jetty-core/jetty-io/src/main/java/org/eclipse/jetty/io/AbstractConnection.java @@ -142,7 +142,7 @@ public boolean isFillInterested() protected void onFillInterestedFailed(Throwable cause) { if (LOG.isDebugEnabled()) - LOG.debug("{} onFillInterestedFailed {}", this, cause); + LOG.debug("onFillInterestedFailed {}", this, cause); if (_endPoint.isOpen()) { boolean close = true; diff --git a/jetty-core/jetty-io/src/main/java/org/eclipse/jetty/io/ArrayByteBufferPool.java b/jetty-core/jetty-io/src/main/java/org/eclipse/jetty/io/ArrayByteBufferPool.java index 38f168180061..1f1d5fbd4ba6 100644 --- a/jetty-core/jetty-io/src/main/java/org/eclipse/jetty/io/ArrayByteBufferPool.java +++ b/jetty-core/jetty-io/src/main/java/org/eclipse/jetty/io/ArrayByteBufferPool.java @@ -26,6 +26,7 @@ import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.LongAdder; import java.util.function.IntUnaryOperator; import java.util.stream.Collectors; @@ -66,6 +67,7 @@ public class ArrayByteBufferPool implements ByteBufferPool, Dumpable private final long _maxDirectMemory; private final IntUnaryOperator _bucketIndexFor; private final AtomicBoolean _evictor = new AtomicBoolean(false); + private final AtomicLong _reserved = new AtomicLong(); private boolean _statisticsEnabled; /** @@ -175,6 +177,12 @@ private long maxMemory(long maxMemory) return maxMemory; } + @ManagedAttribute("The current number of allocated bytes reserved to be added to the pool once released") + public long getReserved() + { + return _reserved.get(); + } + @ManagedAttribute("Whether statistics are enabled") public boolean isStatisticsEnabled() { @@ -214,6 +222,7 @@ public RetainableByteBuffer.Mutable acquire(int size, boolean direct) if (entry == null) { ByteBuffer buffer = BufferUtil.allocate(bucket.getCapacity(), direct); + _reserved.addAndGet(buffer.capacity()); return new ReservedBuffer(buffer, bucket); } @@ -249,6 +258,7 @@ public boolean releaseAndRemove(RetainableByteBuffer buffer) private void reserve(RetainedBucket bucket, ByteBuffer byteBuffer) { + _reserved.addAndGet(-byteBuffer.capacity()); bucket.recordRelease(); // Try to reserve an entry to put the buffer into the pool. diff --git a/jetty-core/jetty-server/src/main/config/etc/jetty-delayed.xml b/jetty-core/jetty-server/src/main/config/etc/jetty-delayed.xml index d1f1fc41ded9..dc08cd0e83c9 100644 --- a/jetty-core/jetty-server/src/main/config/etc/jetty-delayed.xml +++ b/jetty-core/jetty-server/src/main/config/etc/jetty-delayed.xml @@ -1,15 +1,10 @@ - - - - - - + diff --git a/jetty-core/jetty-server/src/main/config/etc/jetty-eager-content.xml b/jetty-core/jetty-server/src/main/config/etc/jetty-eager-content.xml new file mode 100644 index 000000000000..459922747ba6 --- /dev/null +++ b/jetty-core/jetty-server/src/main/config/etc/jetty-eager-content.xml @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/jetty-core/jetty-server/src/main/config/etc/jetty.xml b/jetty-core/jetty-server/src/main/config/etc/jetty.xml index de2bd02e9154..c3d1205a5a91 100644 --- a/jetty-core/jetty-server/src/main/config/etc/jetty.xml +++ b/jetty-core/jetty-server/src/main/config/etc/jetty.xml @@ -71,6 +71,7 @@ + diff --git a/jetty-core/jetty-server/src/main/config/modules/delay-until-content.mod b/jetty-core/jetty-server/src/main/config/modules/delay-until-content.mod index ae8699bb26f6..ffe2c1cb85b7 100644 --- a/jetty-core/jetty-server/src/main/config/modules/delay-until-content.mod +++ b/jetty-core/jetty-server/src/main/config/modules/delay-until-content.mod @@ -1,10 +1,10 @@ [description] -Applies DelayedHandler to entire server. -Delays request handling until any body content has arrived, to minimize blocking. +Applies DEPRECATED DelayedHandler to entire server. +Delays request handling until body content has arrived, to minimize blocking. For form data and multipart, the handling is delayed until the entire request body has -been asynchronously read. For all other content types, the delay is until the first byte -has arrived. +been asynchronously read. For all other content types, the delay is for up to a configurable +number of content bytes. [tags] server @@ -18,3 +18,8 @@ threadlimit [xml] etc/jetty-delayed.xml +[ini-template] +#tag::documentation[] +## The maximum bytes to retain whilst delaying content; or 0 for no delay; or -1 (default) for a default value. +# jetty.delayed.maxRetainedContentBytes=-1 +#end::documentation[] diff --git a/jetty-core/jetty-server/src/main/config/modules/eager-content.mod b/jetty-core/jetty-server/src/main/config/modules/eager-content.mod new file mode 100644 index 000000000000..5cfae2e8b4d2 --- /dev/null +++ b/jetty-core/jetty-server/src/main/config/modules/eager-content.mod @@ -0,0 +1,68 @@ +[description] +Applies the EagerContentHandler to the entire server. +The EagerContentHandler can eagerly load content asynchronously before calling the next handler. +Typically, this handler is deployed before an application that uses blocking IO to read the request body +and if deployed after this handler, the application will never (or rarely) block for request content. +This gives many of the benefits of asynchronous IO without the need to write an asynchronous application. + +[tags] +server + +[depend] +server + +[after] +compression +cross-origin +gzip +rewrite +size-limit + +[before] +qos +thread-limit + +[xml] +etc/jetty-eager-content.xml + +[ini-template] +#tag::documentation[] +## The maximum number of form fields or -1 for a default. +# jetty.eager.form.maxFields=-1 + +## The maximum size of the form in bytes -1 for a default. +# jetty.eager.form.maxLength=-1 + +## The directory where MultiPart parts will be saved as files. +# jetty.eager.multipart.location=/tmp + +## The maximum number of parts that can be parsed from the MultiPart content, or -1 for unlimited. +# jetty.eager.multipart.maxParts=100 + +## The maximum size in bytes of the whole MultiPart content, or -1 for unlimited. +# jetty.eager.multipart.maxSize=52428800 + +## The maximum part size in bytes, or -1 for unlimited. +# jetty.eager.multipart.maxPartSize=10485760 + +## The maximum size of a part in memory, after which it will be written as a file. +# jetty.eager.multipart.maxMemoryPartSize=1024 + +## The max length in bytes of the headers of a part, or -1 for unlimited. +# jetty.eager.multipart.maxHeadersSize=8192 + +## Whether parts without a fileName are stored as files. +# jetty.eager.multipart.useFilesForPartsWithoutFileName=true + +## The MultiPart compliance mode. +# jetty.eager.multipart.complianceMode=RFC7578 + +## The maximum bytes of request content, including framing overhead, to read and retain eagerly, or -1 for a default. +# jetty.eager.content.maxRetainedBytes=-1 + +## The framing overhead to use when calculating the request content bytes to read and retain, or -1 for a default. +# jetty.eager.content.framingOverhead=-1 + +## Whether requests should be rejected if they exceed maxRetainedBytes. +# jetty.eager.content.rejectWhenExceeded=false +#end::documentation[] diff --git a/jetty-core/jetty-server/src/main/config/modules/server.mod b/jetty-core/jetty-server/src/main/config/modules/server.mod index 20dcf29c1946..b9f0bb6449e9 100644 --- a/jetty-core/jetty-server/src/main/config/modules/server.mod +++ b/jetty-core/jetty-server/src/main/config/modules/server.mod @@ -74,6 +74,9 @@ etc/jetty.xml ## Whether to use direct ByteBuffers for reading or writing # jetty.httpConfig.useInputDirectByteBuffers=true # jetty.httpConfig.useOutputDirectByteBuffers=true + +## The minimum space available in a retained input buffer before allocating a new one. +# jetty.httpConfig.minInputBufferSpace=1024 # end::documentation-http-config[] # tag::documentation-server-compliance[] @@ -129,3 +132,4 @@ etc/jetty.xml ## Should the DefaultHandler show a list of known contexts in a root 404 response. # jetty.server.default.showContexts=true + diff --git a/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/FormFields.java b/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/FormFields.java index 8ccb18ded32f..ef6f8aac9902 100644 --- a/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/FormFields.java +++ b/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/FormFields.java @@ -196,9 +196,7 @@ public static void onFields(Request request, Promise.Invocable promise) */ public static void onFields(Request request, Charset charset, Promise.Invocable promise) { - int maxFields = getContextAttribute(request.getContext(), FormFields.MAX_FIELDS_ATTRIBUTE, FormFields.MAX_FIELDS_DEFAULT); - int maxLength = getContextAttribute(request.getContext(), FormFields.MAX_LENGTH_ATTRIBUTE, FormFields.MAX_LENGTH_DEFAULT); - onFields(request, charset, maxFields, maxLength, promise); + onFields(request, charset, -1, -1, promise); } /** @@ -208,12 +206,16 @@ public static void onFields(Request request, Charset charset, Promise.Invocable< * * @param request The request to get or read the Fields from * @param charset The {@link Charset} of the request content, if previously extracted. - * @param maxFields The maximum number of fields to accept - * @param maxLength The maximum length of fields + * @param maxFields The maximum number of fields to accept; or -1 for a default + * @param maxLength The maximum length of fields; or -1 for a default * @param promise The action to take when the FormFields are available. */ public static void onFields(Request request, Charset charset, int maxFields, int maxLength, Promise.Invocable promise) { + if (maxFields < 0) + maxFields = getContextAttribute(request.getContext(), FormFields.MAX_FIELDS_ATTRIBUTE, FormFields.MAX_FIELDS_DEFAULT); + if (maxLength < 0) + maxLength = getContextAttribute(request.getContext(), FormFields.MAX_LENGTH_ATTRIBUTE, FormFields.MAX_LENGTH_DEFAULT); from(request, promise.getInvocationType(), request, charset, maxFields, maxLength).whenComplete(promise); } diff --git a/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/HttpConfiguration.java b/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/HttpConfiguration.java index 505216ee9da2..a1aba0e46695 100644 --- a/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/HttpConfiguration.java +++ b/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/HttpConfiguration.java @@ -90,6 +90,7 @@ public class HttpConfiguration implements Dumpable private HostPort _serverAuthority; private SocketAddress _localAddress; private int _maxUnconsumedRequestContentReads = 16; + private int _minInputBufferSpace = 1500; /** *

An interface that allows a request object to be customized @@ -168,6 +169,7 @@ public HttpConfiguration(HttpConfiguration config) _serverAuthority = config._serverAuthority; _localAddress = config._localAddress; _maxUnconsumedRequestContentReads = config._maxUnconsumedRequestContentReads; + _minInputBufferSpace = config._minInputBufferSpace; } /** @@ -353,13 +355,15 @@ public boolean getSendDateHeader() /** * Set if true, delays the application dispatch until content is available (defaults to true). * @param delay if true, delays the application dispatch until content is available (defaults to true) + * @deprecated Use {@link org.eclipse.jetty.server.handler.EagerContentHandler} instead. */ + @Deprecated (forRemoval = true, since = "12.1.0") public void setDelayDispatchUntilContent(boolean delay) { _delayDispatchUntilContent = delay; } - @ManagedAttribute("Whether to delay the application dispatch until content is available") + @Deprecated (forRemoval = true, since = "12.1.0") public boolean isDelayDispatchUntilContent() { return _delayDispatchUntilContent; @@ -567,6 +571,25 @@ public void setMaxErrorDispatches(int max) _maxErrorDispatches = max; } + /** + * @return The minimum space available in a retained input buffer before allocating a new one. + */ + @ManagedAttribute("The minimum space available in a retained input buffer before allocating a new one") + public int getMinInputBufferSpace() + { + return _minInputBufferSpace; + } + + /** + * @param minInputBufferSpace The minimum space available in a retained input buffer before allocating a new one; + * 0 to always allocate a new buffer; + * -1 for a default value + */ + public void setMinInputBufferSpace(int minInputBufferSpace) + { + _minInputBufferSpace = minInputBufferSpace; + } + /** * @return The minimum request data rate in bytes per second; or <=0 for no limit */ diff --git a/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/HttpConnectionFactory.java b/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/HttpConnectionFactory.java index f1a0bb413ab5..9d069bb297ad 100644 --- a/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/HttpConnectionFactory.java +++ b/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/HttpConnectionFactory.java @@ -15,14 +15,11 @@ import java.util.Objects; -import org.eclipse.jetty.http.ComplianceViolation; import org.eclipse.jetty.http.HttpVersion; import org.eclipse.jetty.io.Connection; import org.eclipse.jetty.io.EndPoint; import org.eclipse.jetty.server.internal.HttpConnection; import org.eclipse.jetty.util.annotation.Name; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; /** * A Connection Factory for HTTP Connections. @@ -32,10 +29,7 @@ */ public class HttpConnectionFactory extends AbstractConnectionFactory implements HttpConfiguration.ConnectionFactory { - private static final Logger LOG = LoggerFactory.getLogger(HttpConnectionFactory.class); private final HttpConfiguration _config; - private boolean _useInputDirectByteBuffers; - private boolean _useOutputDirectByteBuffers; public HttpConnectionFactory() { @@ -47,8 +41,6 @@ public HttpConnectionFactory(@Name("config") HttpConfiguration config) super(HttpVersion.HTTP_1_1.asString()); _config = Objects.requireNonNull(config); installBean(_config); - setUseInputDirectByteBuffers(_config.isUseInputDirectByteBuffers()); - setUseOutputDirectByteBuffers(_config.isUseOutputDirectByteBuffers()); setInputBufferSize(_config.getInputBufferSize()); } @@ -71,53 +63,32 @@ public HttpConfiguration getHttpConfiguration() return _config; } - /** - * @deprecated use {@link HttpConfiguration#getComplianceViolationListeners()} instead to know if there - * are any {@link ComplianceViolation.Listener} to notify. this method will be removed in Jetty 12.1.0 - */ - @Deprecated(since = "12.0.6", forRemoval = true) - public boolean isRecordHttpComplianceViolations() - { - return !_config.getComplianceViolationListeners().isEmpty(); - } - - /** - * Does nothing. - * @deprecated use {@link HttpConfiguration#addComplianceViolationListener(ComplianceViolation.Listener)} instead. - * this method will be removed in Jetty 12.1.0 - */ - @Deprecated(since = "12.0.6", forRemoval = true) - public void setRecordHttpComplianceViolations(boolean recordHttpComplianceViolations) - { - _config.addComplianceViolationListener(new ComplianceViolation.LoggingListener()); - } - public boolean isUseInputDirectByteBuffers() { - return _useInputDirectByteBuffers; + return _config.isUseInputDirectByteBuffers(); } + @Deprecated(forRemoval = true, since = "12.1.0") public void setUseInputDirectByteBuffers(boolean useInputDirectByteBuffers) { - _useInputDirectByteBuffers = useInputDirectByteBuffers; + _config.setUseInputDirectByteBuffers(useInputDirectByteBuffers); } public boolean isUseOutputDirectByteBuffers() { - return _useOutputDirectByteBuffers; + return _config.isUseOutputDirectByteBuffers(); } + @Deprecated(forRemoval = true, since = "12.1.0") public void setUseOutputDirectByteBuffers(boolean useOutputDirectByteBuffers) { - _useOutputDirectByteBuffers = useOutputDirectByteBuffers; + _config.setUseOutputDirectByteBuffers(useOutputDirectByteBuffers); } @Override public Connection newConnection(Connector connector, EndPoint endPoint) { HttpConnection connection = new HttpConnection(_config, connector, endPoint); - connection.setUseInputDirectByteBuffers(isUseInputDirectByteBuffers()); - connection.setUseOutputDirectByteBuffers(isUseOutputDirectByteBuffers()); return configure(connection, connector, endPoint); } } diff --git a/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/handler/DelayedHandler.java b/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/handler/DelayedHandler.java index f3e8fd64a193..5294fc4af539 100644 --- a/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/handler/DelayedHandler.java +++ b/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/handler/DelayedHandler.java @@ -13,328 +13,21 @@ package org.eclipse.jetty.server.handler; -import java.nio.charset.Charset; -import java.nio.charset.StandardCharsets; -import java.util.Objects; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicReference; - -import org.eclipse.jetty.http.HttpField; -import org.eclipse.jetty.http.HttpHeader; -import org.eclipse.jetty.http.HttpHeaderValue; -import org.eclipse.jetty.http.HttpStatus; -import org.eclipse.jetty.http.MimeTypes; -import org.eclipse.jetty.http.MultiPartConfig; -import org.eclipse.jetty.http.MultiPartFormData; -import org.eclipse.jetty.io.Content; -import org.eclipse.jetty.server.FormFields; import org.eclipse.jetty.server.Handler; -import org.eclipse.jetty.server.Request; -import org.eclipse.jetty.server.Response; -import org.eclipse.jetty.util.Callback; -import org.eclipse.jetty.util.Fields; -import org.eclipse.jetty.util.Promise; -import org.eclipse.jetty.util.StringUtil; -public class DelayedHandler extends Handler.Wrapper +/** + * @deprecated Use {@link EagerContentHandler} + */ +@Deprecated(forRemoval = true, since = "12.1.0") +public class DelayedHandler extends EagerContentHandler { - public DelayedHandler() + DelayedHandler() { this(null); } - public DelayedHandler(Handler handler) + DelayedHandler(Handler handler) { super(handler); } - - @Override - public boolean handle(Request request, Response response, Callback callback) throws Exception - { - Handler next = getHandler(); - if (next == null) - return false; - - boolean contentExpected = false; - String contentType = null; - loop: for (HttpField field : request.getHeaders()) - { - HttpHeader header = field.getHeader(); - if (header == null) - continue; - switch (header) - { - case CONTENT_TYPE: - contentType = field.getValue(); - break; - - case CONTENT_LENGTH: - contentExpected = field.getLongValue() > 0; - break; - - case TRANSFER_ENCODING: - contentExpected = field.contains(HttpHeaderValue.CHUNKED.asString()); - break; - - case EXPECT: - if (field.contains(HttpHeaderValue.CONTINUE.asString())) - { - contentExpected = false; - break loop; - } - break; - default: - break; - } - } - - MimeTypes.Type mimeType = MimeTypes.getBaseType(contentType); - DelayedProcess delayed = newDelayedProcess(contentExpected, contentType, mimeType, next, request, response, callback); - if (delayed == null) - return next.handle(request, response, callback); - - delayed.delay(); - return true; - } - - protected DelayedProcess newDelayedProcess(boolean contentExpected, String contentType, MimeTypes.Type mimeType, Handler handler, Request request, Response response, Callback callback) - { - // if no content is expected, then no delay - if (!contentExpected) - return null; - - // if we are not configured to delay dispatch, then no delay - if (!request.getConnectionMetaData().getHttpConfiguration().isDelayDispatchUntilContent()) - return null; - - // If there is no known content type, then delay only until content is available - if (mimeType == null) - return new UntilContentDelayedProcess(handler, request, response, callback); - - // Otherwise, delay until a known content type is fully read; or if the type is not known then until the content is available - return switch (mimeType) - { - case FORM_ENCODED -> new UntilFormDelayedProcess(handler, request, response, callback, contentType); - case MULTIPART_FORM_DATA -> - { - if (request.getContext().getAttribute(MultiPartConfig.class.getName()) instanceof MultiPartConfig mpc) - yield new UntilMultipartDelayedProcess(handler, request, response, callback, contentType, mpc); - if (getServer().getAttribute(MultiPartConfig.class.getName()) instanceof MultiPartConfig mpc) - yield new UntilMultipartDelayedProcess(handler, request, response, callback, contentType, mpc); - yield null; - } - default -> new UntilContentDelayedProcess(handler, request, response, callback); - }; - } - - protected abstract static class DelayedProcess - { - private final Handler _handler; - private final Request _request; - private final Response _response; - private final Callback _callback; - - protected DelayedProcess(Handler handler, Request request, Response response, Callback callback) - { - _handler = Objects.requireNonNull(handler); - _request = Objects.requireNonNull(request); - _response = Objects.requireNonNull(response); - _callback = Objects.requireNonNull(callback); - } - - protected Handler getHandler() - { - return _handler; - } - - protected Request getRequest() - { - return _request; - } - - protected Response getResponse() - { - return _response; - } - - protected Callback getCallback() - { - return _callback; - } - - protected void process() - { - try - { - if (!getHandler().handle(getRequest(), getResponse(), getCallback())) - Response.writeError(getRequest(), getResponse(), getCallback(), HttpStatus.NOT_FOUND_404); - } - catch (Throwable t) - { - Response.writeError(getRequest(), getResponse(), getCallback(), t); - } - } - - protected abstract void delay() throws Exception; - } - - protected static class UntilContentDelayedProcess extends DelayedProcess - { - public UntilContentDelayedProcess(Handler handler, Request request, Response response, Callback callback) - { - super(handler, request, response, callback); - } - - @Override - protected void delay() - { - Content.Chunk chunk = super.getRequest().read(); - if (chunk == null) - { - getRequest().demand(org.eclipse.jetty.util.thread.Invocable.from(InvocationType.NON_BLOCKING, this::onContent)); - } - else - { - RewindChunkRequest request = new RewindChunkRequest(getRequest(), chunk); - try - { - getHandler().handle(request, getResponse(), getCallback()); - } - catch (Throwable x) - { - // Use the wrapped request so that the error handling can - // consume the request content and release the already read chunk. - Response.writeError(request, getResponse(), getCallback(), x); - } - } - } - - public void onContent() - { - // We must execute here, because demand callbacks are serialized and process may block on a demand callback - getRequest().getContext().execute(this::process); - } - - private static class RewindChunkRequest extends Request.Wrapper - { - private final AtomicReference _chunk; - - public RewindChunkRequest(Request wrapped, Content.Chunk chunk) - { - super(wrapped); - _chunk = new AtomicReference<>(chunk); - } - - @Override - public Content.Chunk read() - { - Content.Chunk chunk = _chunk.getAndSet(null); - if (chunk != null) - return chunk; - return super.read(); - } - } - } - - protected static class UntilFormDelayedProcess extends DelayedProcess - { - private final Charset _charset; - - public UntilFormDelayedProcess(Handler handler, Request wrapped, Response response, Callback callback, String contentType) - { - super(handler, wrapped, response, callback); - - String cs = MimeTypes.getCharsetFromContentType(contentType); - _charset = StringUtil.isEmpty(cs) ? StandardCharsets.UTF_8 : Charset.forName(cs); - } - - @Override - protected void delay() - { - InvocationType invocationType = getHandler().getInvocationType(); - AtomicInteger done = new AtomicInteger(2); - var onFields = new Promise.Invocable() - { - @Override - public void failed(Throwable x) - { - Response.writeError(getRequest(), getResponse(), getCallback(), x); - } - - @Override - public void succeeded(Fields result) - { - if (done.decrementAndGet() == 0) - invocationType.runWithoutBlocking(this::doProcess, getRequest().getContext()); - } - - private void doProcess() - { - process(); - } - - @Override - public InvocationType getInvocationType() - { - return invocationType; - } - }; - - FormFields.onFields(getRequest(), _charset, onFields); - if (done.decrementAndGet() == 0) - process(); - } - } - - protected static class UntilMultipartDelayedProcess extends DelayedProcess - { - private final String _contentType; - private final MultiPartConfig _config; - - public UntilMultipartDelayedProcess(Handler handler, Request request, Response response, Callback callback, String contentType, MultiPartConfig config) - { - super(handler, request, response, callback); - _contentType = contentType; - _config = config; - } - - @Override - protected void delay() - { - Request request = getRequest(); - InvocationType invocationType = getHandler().getInvocationType(); - AtomicInteger done = new AtomicInteger(2); - - Promise.Invocable onParts = new Promise.Invocable<>() - { - @Override - public void failed(Throwable x) - { - succeeded(null); - } - - @Override - public void succeeded(MultiPartFormData.Parts result) - { - if (done.decrementAndGet() == 0) - invocationType.runWithoutBlocking(this::doProcess, getRequest().getContext()); - } - - private void doProcess() - { - process(); - } - - @Override - public InvocationType getInvocationType() - { - return invocationType; - } - }; - - MultiPartFormData.onParts(request, request, _contentType, _config, onParts); - if (done.decrementAndGet() == 0) - process(); - } - } } diff --git a/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/handler/EagerContentHandler.java b/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/handler/EagerContentHandler.java new file mode 100644 index 000000000000..1ab28bee4868 --- /dev/null +++ b/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/handler/EagerContentHandler.java @@ -0,0 +1,621 @@ +// +// ======================================================================== +// Copyright (c) 1995 Mort Bay Consulting Pty Ltd and others. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 +// which is available at https://www.apache.org/licenses/LICENSE-2.0. +// +// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 +// ======================================================================== +// + +package org.eclipse.jetty.server.handler; + +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.Map; +import java.util.Objects; +import java.util.TreeMap; +import java.util.concurrent.atomic.AtomicInteger; + +import org.eclipse.jetty.http.HttpField; +import org.eclipse.jetty.http.HttpHeader; +import org.eclipse.jetty.http.HttpHeaderValue; +import org.eclipse.jetty.http.HttpStatus; +import org.eclipse.jetty.http.HttpVersion; +import org.eclipse.jetty.http.MimeTypes; +import org.eclipse.jetty.http.MultiPartConfig; +import org.eclipse.jetty.http.MultiPartFormData; +import org.eclipse.jetty.io.Content; +import org.eclipse.jetty.server.FormFields; +import org.eclipse.jetty.server.Handler; +import org.eclipse.jetty.server.HttpConnectionFactory; +import org.eclipse.jetty.server.Request; +import org.eclipse.jetty.server.Response; +import org.eclipse.jetty.util.Attributes; +import org.eclipse.jetty.util.Callback; +import org.eclipse.jetty.util.Fields; +import org.eclipse.jetty.util.Promise; +import org.eclipse.jetty.util.StringUtil; +import org.eclipse.jetty.util.thread.Invocable; + +/** + *

A {@link ConditionalHandler} that can eagerly load content asynchronously before calling the + * {@link #getHandler() next handler}. Typically this handler is deployed before an application that uses + * blocking IO to read the request body. By using this handler, such an application can be run in a way so that it + * never (or seldom) blocks on request content. This gives many of the benefits of asynchronous IO without the + * need to write an asynchronous application. + *

+ *

The handler uses the configured {@link FormContentLoaderFactory} instances to eagerly load specific content types. + * By default, this handler supports eager loading of:

+ *
+ *
{@link FormFields}
Loaded and parsed in full by the {@link FormContentLoaderFactory}
+ *
{@link MultiPartFormData}
Loaded and parsed in full by the {@link MultiPartContentLoaderFactory}
+ *
{@link Content.Chunk}
Retained by the {@link RetainedContentLoaderFactory}
+ *
+ */ +public class EagerContentHandler extends ConditionalHandler.ElseNext +{ + private final Map _factoriesByMimeType = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + private final ContentLoaderFactory _defaultFactory; + + /** + * Construct an {@code EagerContentHandler} with the default {@link ContentLoaderFactory} set + */ + public EagerContentHandler() + { + this((Handler)null); + } + + /** + * Construct an {@code EagerContentHandler} with the default {@link ContentLoaderFactory} set + * @param handler The next handler (also can be set with {@link #setHandler(Handler)} + */ + public EagerContentHandler(Handler handler) + { + this(handler, new FormContentLoaderFactory(), new MultiPartContentLoaderFactory(), new RetainedContentLoaderFactory()); + } + + /** + * Construct an {@code EagerContentHandler} with the specific {@link ContentLoaderFactory} instances + * @param factories The {@link ContentLoaderFactory} instances used to eagerly load content. + */ + public EagerContentHandler(ContentLoaderFactory... factories) + { + this(null, factories); + } + + /** + * Construct an {@code EagerContentHandler} with the specific {@link ContentLoaderFactory} instances + * @param handler The next handler (also can be set with {@link #setHandler(Handler)} + * @param factories The {@link ContentLoaderFactory} instances used to eagerly load content. + */ + public EagerContentHandler(Handler handler, ContentLoaderFactory... factories) + { + super(handler); + ContentLoaderFactory dft = null; + for (ContentLoaderFactory factory : factories) + { + installBean(factory); + if (factory.getApplicableMimeType() == null) + dft = factory; + else + _factoriesByMimeType.put(factory.getApplicableMimeType(), factory); + } + _defaultFactory = dft; + } + + @Override + protected boolean onConditionsMet(Request request, Response response, Callback callback) throws Exception + { + Handler next = getHandler(); + if (next == null) + return false; + + boolean contentExpected = false; + String contentType = null; + String mimeType = null; + loop: + for (HttpField field : request.getHeaders()) + { + HttpHeader header = field.getHeader(); + if (header == null) + continue; + switch (header) + { + case CONTENT_TYPE: + contentType = field.getValue(); + mimeType = MimeTypes.getMimeTypeAsStringFromContentType(field); + break; + + case CONTENT_LENGTH: + contentExpected = field.getLongValue() > 0; + break; + + case TRANSFER_ENCODING: + contentExpected = field.contains(HttpHeaderValue.CHUNKED.asString()); + break; + + default: + break; + } + } + + if (!contentExpected) + return next.handle(request, response, callback); + + ContentLoaderFactory factory = mimeType == null ? null : _factoriesByMimeType.get(mimeType); + if (factory == null) + factory = _defaultFactory; + if (factory == null) + return next.handle(request, response, callback); + + ContentLoader contentLoader = factory.newContentLoader(contentType, mimeType, next, request, response, callback); + if (contentLoader == null) + return next.handle(request, response, callback); + + contentLoader.load(); + return true; + } + + /** + * A factory to create new {@link ContentLoader} instances for a specific mime type. + */ + public interface ContentLoaderFactory + { + /** + * @return The mimetype for which this factory is applicable to; or {@code null} if applicable to all types. + */ + String getApplicableMimeType(); + + /** + * @param contentType The content type of the request + * @param mimeType The mime type extracted from the request + * @param handler The next handler to call + * @param request The request + * @param response The response + * @param callback The callback + * @return An {@link ContentLoader} instance if the content can be loaded eagerly, else {@code null}. + */ + ContentLoader newContentLoader(String contentType, String mimeType, Handler handler, Request request, Response response, Callback callback); + } + + /** + * An eager content processor, created by a {@link ContentLoaderFactory} to asynchronous load content from a {@link Request} + * before calling the {@link Handler#handle(Request, Response, Callback)} method of the passed {@link Handler}. + */ + public abstract static class ContentLoader + { + private final Handler _handler; + private final Request _request; + private final Response _response; + private final Callback _callback; + + protected ContentLoader(Handler handler, Request request, Response response, Callback callback) + { + _handler = Objects.requireNonNull(handler); + _request = Objects.requireNonNull(request); + _response = Objects.requireNonNull(response); + _callback = Objects.requireNonNull(callback); + } + + protected Handler getHandler() + { + return _handler; + } + + protected Request getRequest() + { + return _request; + } + + protected Response getResponse() + { + return _response; + } + + protected Callback getCallback() + { + return _callback; + } + + protected void handle() + { + handle(getRequest(), getResponse(), getCallback()); + } + + protected void handle(Request request, Response response, Callback callback) + { + try + { + if (getHandler().handle(request, response, callback)) + return; + + // The handle was rejected, so write the error using the original potentially unwrapped request/response/callback + Response.writeError(request, response, callback, HttpStatus.NOT_FOUND_404); + } + catch (Throwable t) + { + // The handle failed, so write the error using the original potentially unwrapped request/response/callback + Response.writeError(request, response, callback, t); + } + } + + /** + * Called to initiate eager loading of the content. The content may be loaded within the scope + * of this method, or within the scope of a callback as a result of a {@link Request#demand(Runnable)} call made by + * this methhod. + * @throws Exception If there is a problem + */ + protected abstract void load() throws Exception; + } + + /** + * An {@link ContentLoaderFactory} for {@link MimeTypes.Type#FORM_ENCODED} content, that uses + * {@link FormFields#onFields(Request, Charset, int, int, Promise.Invocable)} to asynchronously load and parse the content. + */ + public static class FormContentLoaderFactory implements ContentLoaderFactory + { + private final int _maxFields; + private final int _maxLength; + + public FormContentLoaderFactory() + { + this(-1, -1); + } + + /** + * @param maxFields The maximum number of fields to be eagerly loaded; + * or -1 to use the default of {@link FormFields#onFields(Request, Charset, int, int, Promise.Invocable)} + * @param maxLength The maximum length of all combined fields to be eagerly loaded; + * or -1 to use the default of {@link FormFields#onFields(Request, Charset, int, int, Promise.Invocable)} + */ + public FormContentLoaderFactory(int maxFields, int maxLength) + { + _maxFields = maxFields; + _maxLength = maxLength; + } + + @Override + public String getApplicableMimeType() + { + return MimeTypes.Type.FORM_ENCODED.asString(); + } + + @Override + public ContentLoader newContentLoader(String contentType, String mimeType, Handler handler, Request request, Response response, Callback callback) + { + String cs = MimeTypes.getCharsetFromContentType(contentType); + Charset charset = StringUtil.isEmpty(cs) ? StandardCharsets.UTF_8 : Charset.forName(cs); + + return new ContentLoader(handler, request, response, callback) + { + @Override + protected void load() + { + InvocationType invocationType = getHandler().getInvocationType(); + AtomicInteger done = new AtomicInteger(2); + var onFields = new Promise.Invocable() + { + @Override + public void failed(Throwable x) + { + succeeded(null); + } + + @Override + public void succeeded(Fields result) + { + // If the handling thread has already exited, we must process without blocking from this callback + if (done.decrementAndGet() == 0) + invocationType.runWithoutBlocking(this::doProcess, getRequest().getContext()); + } + + private void doProcess() + { + handle(); + } + + @Override + public InvocationType getInvocationType() + { + return invocationType; + } + }; + + // If the fields are already available, we can process from this handling thread + FormFields.onFields(getRequest(), charset, _maxFields, _maxLength, onFields); + if (done.decrementAndGet() == 0) + handle(); + } + }; + } + } + + /** + * An {@link ContentLoaderFactory} for {@link MimeTypes.Type#MULTIPART_FORM_DATA} content, that uses + * {@link MultiPartFormData#onParts(Content.Source, Attributes, String, MultiPartConfig, Promise.Invocable)} + * to asynchronously load and parse the content. + */ + public static class MultiPartContentLoaderFactory implements ContentLoaderFactory + { + private final MultiPartConfig _multiPartConfig; + + public MultiPartContentLoaderFactory() + { + this(null); + } + + /** + * @param multiPartConfig The {@link MultiPartConfig} to use for eagerly loading content; + * or {@code null} to look for a {@link MultiPartConfig} as a + * {@link org.eclipse.jetty.server.Context} or {@link org.eclipse.jetty.server.Server} + * {@link Attributes attribute}, using the class name as the attribute name. + */ + public MultiPartContentLoaderFactory(MultiPartConfig multiPartConfig) + { + _multiPartConfig = multiPartConfig; + } + + @Override + public String getApplicableMimeType() + { + return MimeTypes.Type.MULTIPART_FORM_DATA.asString(); + } + + @Override + public ContentLoader newContentLoader(String contentType, String mimeType, Handler handler, Request request, Response response, Callback callback) + { + MultiPartConfig config = _multiPartConfig; + if (config == null && request.getContext().getAttribute(MultiPartConfig.class.getName()) instanceof MultiPartConfig mpc) + config = mpc; + if (config == null && handler.getServer().getAttribute(MultiPartConfig.class.getName()) instanceof MultiPartConfig mpc) + config = mpc; + if (config == null) + return null; + + MultiPartConfig multiPartConfig = config; + + return new ContentLoader(handler, request, response, callback) + { + @Override + protected void load() + { + Request request = getRequest(); + InvocationType invocationType = getHandler().getInvocationType(); + AtomicInteger done = new AtomicInteger(2); + + Promise.Invocable onParts = new Promise.Invocable<>() + { + @Override + public void failed(Throwable x) + { + succeeded(null); + } + + @Override + public void succeeded(MultiPartFormData.Parts result) + { + // If the handling thread has already exited, we must process without blocking from this callback + if (done.decrementAndGet() == 0) + invocationType.runWithoutBlocking(this::doProcess, getRequest().getContext()); + } + + private void doProcess() + { + handle(); + } + + @Override + public InvocationType getInvocationType() + { + return invocationType; + } + }; + + MultiPartFormData.onParts(request, request, contentType, multiPartConfig, onParts); + + // If the parts are already available, we can process from this handling thread + if (done.decrementAndGet() == 0) + handle(); + } + }; + } + } + + /** + * An {@link ContentLoaderFactory} for any content, that uses {@link Content.Chunk#retain()} to + * eagerly load content with zero copies, until all content is read or a maximum size is exceeded. + */ + public static class RetainedContentLoaderFactory implements ContentLoaderFactory + { + private final long _maxRetainedBytes; + private final int _framingOverhead; + private final boolean _reject; + + public RetainedContentLoaderFactory() + { + this(-1, -1, true); + } + + /** + * @param maxRetainedBytes the maximum number bytes to retain whilst eagerly loading, which + * includes the content bytes and any {@code framingOverhead} per chunk; + * or -1 for a heuristically determined value that will not increase memory commitment. + * @param framingOverhead the number of bytes to include in the estimated size per {@link Content.Chunk} to allow + * for framing overheads in the transport. Since the content is retained rather than copied, any + * framing data is also retained in the IO buffer. + * @param reject if {@code true}, then if {@code maxRetainBytes} is exceeded, the request is rejected with a + * {@link HttpStatus#PAYLOAD_TOO_LARGE_413} response. + */ + public RetainedContentLoaderFactory(long maxRetainedBytes, int framingOverhead, boolean reject) + { + _maxRetainedBytes = maxRetainedBytes; + _framingOverhead = framingOverhead; + _reject = reject; + } + + @Override + public String getApplicableMimeType() + { + return null; + } + + @Override + public ContentLoader newContentLoader(String contentType, String mimeType, Handler handler, Request request, Response response, Callback callback) + { + return new RetainedContentLoader(handler, request, response, callback, _maxRetainedBytes, _framingOverhead, _reject); + } + + /** + * Delay dispatch until all content or an effective buffer size is reached + */ + public static class RetainedContentLoader extends ContentLoader implements Invocable.Task + { + private final Deque _chunks = new ArrayDeque<>(); + private final long _maxRetainedBytes; + private final int _framingOverhead; + private final boolean _rejectWhenExceeded; + private long _estimatedSize; + + /** + * @param handler The next handler + * @param request The delayed request + * @param response The delayed response + * @param callback The delayed callback + * @param maxRetainedBytes The maximum size to buffer before dispatching to the next handler; + * or -1 for a heuristically determined default + * @param framingOverhead The bytes to account for per chunk when calculating the size; or -1 for a heuristic. + * @param rejectWhenExceeded If {@code true} then requests are rejected if the content is not complete before maxRetainedBytes. + */ + public RetainedContentLoader(Handler handler, Request request, Response response, Callback callback, long maxRetainedBytes, int framingOverhead, boolean rejectWhenExceeded) + { + super(handler, request, response, callback); + _maxRetainedBytes = maxRetainedBytes < 0 + ? Math.max(1, request.getConnectionMetaData().getConnector().getConnectionFactory(HttpConnectionFactory.class).getInputBufferSize() - 1500) + : maxRetainedBytes; + _framingOverhead = framingOverhead < 0 + ? (request.getConnectionMetaData().getHttpVersion().getVersion() <= HttpVersion.HTTP_1_1.getVersion() ? 8 : 9) + : framingOverhead; + _rejectWhenExceeded = rejectWhenExceeded; + } + + @Override + protected void load() + { + read(false); + } + + protected void read(boolean execute) + { + while (true) + { + Content.Chunk chunk = super.getRequest().read(); + if (chunk == null) + { + getRequest().demand(this); + break; + } + + // retain the chunk in the queue + if (!_chunks.add(chunk)) + { + getCallback().failed(new IllegalStateException()); + break; + } + + // Estimated size is 8 byte framing overhead per chunk plus the chunk size + _estimatedSize += _framingOverhead + chunk.remaining(); + + boolean oversize = _estimatedSize >= _maxRetainedBytes; + + if (_rejectWhenExceeded && oversize && !chunk.isLast()) + { + Response.writeError(getRequest(), getResponse(), getCallback(), HttpStatus.PAYLOAD_TOO_LARGE_413); + break; + } + + if (chunk.isLast() || oversize) + { + if (execute) + getRequest().getContext().execute(this::doHandle); + else + doHandle(); + break; + } + } + } + + @Override + public InvocationType getInvocationType() + { + return InvocationType.NON_BLOCKING; + } + + /** + * This is run when enough content has been received to dispatch to the next handler. + */ + public void run() + { + read(true); + } + + private void doHandle() + { + RewindChunksRequest request = new RewindChunksRequest(getRequest(), getCallback(), _chunks); + handle(request, getResponse(), request); + } + + private static class RewindChunksRequest extends Request.Wrapper implements Callback + { + private final Deque _chunks; + private final Callback _callback; + + public RewindChunksRequest(Request wrapped, Callback callback, Deque chunks) + { + super(wrapped); + _chunks = chunks; + _callback = callback; + } + + @Override + public InvocationType getInvocationType() + { + return _callback.getInvocationType(); + } + + @Override + public Content.Chunk read() + { + if (_chunks.isEmpty()) + return super.read(); + return _chunks.removeFirst(); + } + + private void release() + { + _chunks.forEach(Content.Chunk::release); + _chunks.clear(); + } + + @Override + public void succeeded() + { + release(); + _callback.succeeded(); + } + + @Override + public void fail(Throwable failure) + { + release(); + _callback.failed(failure); + } + } + } + } +} diff --git a/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/internal/HttpChannelState.java b/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/internal/HttpChannelState.java index b315e9353806..39ac531a7b8c 100644 --- a/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/internal/HttpChannelState.java +++ b/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/internal/HttpChannelState.java @@ -363,6 +363,10 @@ public Runnable onIdleTimeout(TimeoutException t) if (LOG.isDebugEnabled()) LOG.debug("onIdleTimeout {}", this, t); + // too late? + if (_stream == null) + return null; + Runnable invokeOnContentAvailable = null; if (_readFailure == null) { @@ -441,7 +445,8 @@ private Runnable onFailure(Throwable x, boolean remote) // If not handled, then we just fail the request callback if (!_handled && _handling == null) { - task = () -> _request._callback.failed(x); + Callback callback = _request._callback; + task = () -> callback.failed(x); } else { @@ -709,6 +714,7 @@ public void succeeded() try (AutoLock ignored = _lock.lock()) { assert _callbackCompleted; + assert _callbackFailure == null; _streamSendState = StreamSendState.LAST_COMPLETE; completeStream = _handling == null; stream = _stream; diff --git a/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/internal/HttpConnection.java b/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/internal/HttpConnection.java index 2c1e6733da72..94e60222ccac 100644 --- a/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/internal/HttpConnection.java +++ b/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/internal/HttpConnection.java @@ -25,7 +25,6 @@ import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicReference; -import java.util.concurrent.atomic.LongAdder; import org.eclipse.jetty.http.BadMessageException; import org.eclipse.jetty.http.ComplianceViolation; @@ -101,17 +100,16 @@ public class HttpConnection extends AbstractMetaDataConnection implements Runnab private final Lazy _attributes = new Lazy(); private final DemandContentCallback _demandContentCallback = new DemandContentCallback(); private final SendCallback _sendCallback = new SendCallback(); - private final LongAdder bytesIn = new LongAdder(); - private final LongAdder bytesOut = new LongAdder(); private final AtomicBoolean _handling = new AtomicBoolean(false); private final HttpFields.Mutable _headerBuilder = HttpFields.build(); + private final int _minBufferSpace; private volatile RetainableByteBuffer _requestBuffer; private HttpFields.Mutable _trailers; private Runnable _onRequest; - private long _requests; - // TODO why is this not on HttpConfiguration? - private boolean _useInputDirectByteBuffers; - private boolean _useOutputDirectByteBuffers; + private final AtomicLong _requests = new AtomicLong(); + private final AtomicLong _responses = new AtomicLong(); + private final AtomicLong _bytesIn = new AtomicLong(); + private final AtomicLong _bytesOut = new AtomicLong(); /** * Get the current connection that this thread is dispatched to. @@ -133,15 +131,6 @@ protected static HttpConnection setCurrentConnection(HttpConnection connection) return last; } - /** - * @deprecated use {@link #HttpConnection(HttpConfiguration, Connector, EndPoint)} instead. Will be removed in Jetty 12.1.0 - */ - @Deprecated(since = "12.0.6", forRemoval = true) - public HttpConnection(HttpConfiguration configuration, Connector connector, EndPoint endPoint, boolean recordComplianceViolations) - { - this(configuration, connector, endPoint); - } - public HttpConnection(HttpConfiguration configuration, Connector connector, EndPoint endPoint) { super(connector, configuration, endPoint); @@ -151,6 +140,8 @@ public HttpConnection(HttpConfiguration configuration, Connector connector, EndP _httpChannel = newHttpChannel(connector.getServer(), configuration); _requestHandler = newRequestHandler(); _parser = newHttpParser(configuration.getHttpCompliance()); + _minBufferSpace = configuration.getMinInputBufferSpace() < 0 ? Math.min(1500, configuration.getInputBufferSize()) : configuration.getMinInputBufferSpace(); + if (LOG.isDebugEnabled()) LOG.debug("New HTTP Connection {}", this); } @@ -161,15 +152,6 @@ public InvocationType getInvocationType() return getServer().getInvocationType(); } - /** - * @deprecated No replacement, no longer used within {@link HttpConnection}, will be removed in Jetty 12.1.0 - */ - @Deprecated(since = "12.0.6", forRemoval = true) - public boolean isRecordHttpComplianceViolations() - { - return false; - } - protected HttpGenerator newHttpGenerator() { HttpGenerator generator = new HttpGenerator(); @@ -288,35 +270,35 @@ public void clearAttributes() @Override public long getMessagesIn() { - return _requests; + return _requests.get(); } @Override public long getMessagesOut() { - return _requests; // TODO not strictly correct + return _responses.get(); } public boolean isUseInputDirectByteBuffers() { - return _useInputDirectByteBuffers; + return getHttpConfiguration().isUseInputDirectByteBuffers(); } + @Deprecated(forRemoval = true, since = "12.1.0") public void setUseInputDirectByteBuffers(boolean useInputDirectByteBuffers) { - // TODO why is this not on HttpConfiguration? - _useInputDirectByteBuffers = useInputDirectByteBuffers; + getHttpConfiguration().setUseInputDirectByteBuffers(useInputDirectByteBuffers); } public boolean isUseOutputDirectByteBuffers() { - return _useOutputDirectByteBuffers; + return getHttpConfiguration().isUseOutputDirectByteBuffers(); } + @Deprecated(forRemoval = true, since = "12.1.0") public void setUseOutputDirectByteBuffers(boolean useOutputDirectByteBuffers) { - // TODO why is this not on HttpConfiguration? - _useOutputDirectByteBuffers = useOutputDirectByteBuffers; + getHttpConfiguration().setUseOutputDirectByteBuffers(useOutputDirectByteBuffers); } @Override @@ -348,9 +330,10 @@ void releaseRequestBuffer() { if (LOG.isDebugEnabled()) LOG.debug("releasing request buffer {} {}", _requestBuffer, this); - if (_requestBuffer != null) - _requestBuffer.release(); + RetainableByteBuffer buffer = _requestBuffer; _requestBuffer = null; + if (buffer != null) + buffer.release(); } private void ensureRequestBuffer() @@ -383,12 +366,20 @@ public void onFillable() // Note that the endpoint might already be closed in some special circumstances. while (true) { - int filled = fillRequestBuffer(); - if (LOG.isDebugEnabled()) - LOG.debug("onFillable filled {} {} {} {}", filled, _httpChannel, _requestBuffer, this); + int filled; + if (isRequestBufferEmpty()) + { + filled = fillRequestBuffer(true); + if (LOG.isDebugEnabled()) + LOG.debug("onFillable filled {} {} {} {}", filled, _httpChannel, _requestBuffer, this); - if (filled < 0 && getEndPoint().isOutputShutdown()) - close(); + if (filled < 0 && getEndPoint().isOutputShutdown()) + close(); + } + else + { + filled = 0; + } boolean handle = parseRequestBuffer(); @@ -527,31 +518,51 @@ void parseAndFillForContent() assert !_requestBuffer.hasRemaining(); + int filled; + boolean compact = true; + + // If the application has retained the content chunks then we must not overwrite content. if (_requestBuffer.isRetained()) { - // The application has retained the content chunks, - // reacquire the buffer to avoid overwriting the content. - releaseRequestBuffer(); - ensureRequestBuffer(); + // If there is sufficient space available, we can top up the buffer rather than allocate a new one + ByteBuffer backing = _requestBuffer.getByteBuffer(); + if (_minBufferSpace > 0 && BufferUtil.space(backing) >= _minBufferSpace) + { + // do not compact the buffer + compact = false; + } + else + { + // otherwise reacquire the buffer and fill into the new buffer. + releaseRequestBuffer(); + ensureRequestBuffer(); + } } - int filled = fillRequestBuffer(); + filled = fillRequestBuffer(compact); + if (filled <= 0) { - releaseRequestBuffer(); + // Keep the buffer if it is retained + if (filled < 0 || !_requestBuffer.isRetained()) + releaseRequestBuffer(); break; } } } - private int fillRequestBuffer() + private int fillRequestBuffer(boolean compact) { - if (!isRequestBufferEmpty()) - return _requestBuffer.remaining(); - + int padding = 0; + ByteBuffer requestBuffer = _requestBuffer.getByteBuffer(); try { - ByteBuffer requestBuffer = _requestBuffer.getByteBuffer(); + if (!compact) + { + // Add padding content to avoid compaction + padding = requestBuffer.limit(); + requestBuffer.position(0); + } int filled = getEndPoint().fill(requestBuffer); if (filled == 0) // Do a retry on fill 0 (optimization for SSL connections) filled = getEndPoint().fill(requestBuffer); @@ -560,7 +571,7 @@ private int fillRequestBuffer() LOG.debug("filled {} {} {}", filled, _requestBuffer, this); if (filled > 0) - bytesIn.add(filled); + _bytesIn.addAndGet(filled); else if (filled < 0) _parser.atEOF(); @@ -573,6 +584,11 @@ else if (filled < 0) _parser.atEOF(); return -1; } + finally + { + if (!compact && padding > 0) + requestBuffer.position(padding); + } } private boolean parseRequestBuffer() @@ -655,13 +671,13 @@ public void asyncReadFillInterested() @Override public long getBytesIn() { - return bytesIn.longValue(); + return _bytesIn.get(); } @Override public long getBytesOut() { - return bytesOut.longValue(); + return _bytesOut.get(); } @Override @@ -822,7 +838,7 @@ public Action process() throws Exception gatherWrite += 1; bytes += _content.remaining(); } - HttpConnection.this.bytesOut.add(bytes); + _bytesOut.addAndGet(bytes); switch (gatherWrite) { case 7: @@ -1254,7 +1270,7 @@ public boolean is100ContinueExpected() }; Runnable handle = _httpChannel.onRequest(_request); - ++_requests; + _requests.incrementAndGet(); Request request = _httpChannel.getRequest(); getHttpChannel().getComplianceViolationListener().onRequestBegin(request); @@ -1416,18 +1432,23 @@ public void send(MetaData.Request request, MetaData.Response response, boolean l else if (_generator.isCommitted()) { callback.failed(new IllegalStateException("Committed")); + return; } - else if (_expects100Continue) + else { - if (response.getStatus() == HttpStatus.CONTINUE_100) - { - _expects100Continue = false; - } - else + _responses.incrementAndGet(); + if (_expects100Continue) { - // Expecting to send a 100 Continue response, but it's a different response, - // then cannot be persistent because likely the client did not send the content. - _generator.setPersistent(false); + if (response.getStatus() == HttpStatus.CONTINUE_100) + { + _expects100Continue = false; + } + else + { + // Expecting to send a 100 Continue response, but it's a different response, + // then cannot be persistent because likely the client did not send the content. + _generator.setPersistent(false); + } } } diff --git a/jetty-core/jetty-server/src/test/java/org/eclipse/jetty/server/HttpConnectionTest.java b/jetty-core/jetty-server/src/test/java/org/eclipse/jetty/server/HttpConnectionTest.java index c356c7199a21..b01072335fce 100644 --- a/jetty-core/jetty-server/src/test/java/org/eclipse/jetty/server/HttpConnectionTest.java +++ b/jetty-core/jetty-server/src/test/java/org/eclipse/jetty/server/HttpConnectionTest.java @@ -21,6 +21,7 @@ package org.eclipse.jetty.server; import java.io.BufferedReader; +import java.io.IOException; import java.io.StringReader; import java.nio.charset.StandardCharsets; import java.util.ArrayList; @@ -28,7 +29,9 @@ import java.util.EnumSet; import java.util.HashSet; import java.util.List; +import java.util.Queue; import java.util.Set; +import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; @@ -47,6 +50,7 @@ import org.eclipse.jetty.logging.StacklessLogging; import org.eclipse.jetty.server.handler.DumpHandler; import org.eclipse.jetty.server.internal.HttpConnection; +import org.eclipse.jetty.util.Blocker; import org.eclipse.jetty.util.BufferUtil; import org.eclipse.jetty.util.Callback; import org.eclipse.jetty.util.NanoTime; @@ -69,6 +73,7 @@ import static org.hamcrest.Matchers.startsWith; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; public class HttpConnectionTest @@ -1702,4 +1707,97 @@ public boolean handle(Request request, Response response, Callback callback) thr else assertThat(response.get(HttpHeader.CONNECTION), is(expectedConnectionHeader)); } + + @Test + public void testRetainedChunks() throws Exception + { + Queue chunks = new ConcurrentLinkedQueue<>(); + CountDownLatch blocked = new CountDownLatch(1); + + _server.setHandler(new Handler.Abstract() + { + @Override + public boolean handle(Request request, Response response, Callback callback) + { + while (true) + { + Content.Chunk chunk = request.read(); + if (chunk == null) + { + try (Blocker.Runnable blocker = Blocker.runnable()) + { + blocked.countDown(); + request.demand(blocker); + blocker.block(); + } + catch (IOException e) + { + // ignored + } + continue; + } + + chunks.add(chunk); + if (chunk.isLast()) + break; + } + callback.succeeded(); + return true; + } + }); + _server.start(); + + LocalConnector.LocalEndPoint localEndPoint = _connector.executeRequest(""" + POST / HTTP/1.1\r + Host: localhost\r + Transfer-Encoding: chunked\r + \r + 3;\r + one\r + 3;\r + two\r + 5;\r + """); + + // Wait for the server to block on the read(). + blocked.await(5, TimeUnit.SECONDS); + + // Send more content. + localEndPoint.addInput(""" + three\r + 4;\r + four\r + 4;\r + five\r + 3;\r + si"""); + + // Send more content. + localEndPoint.addInput(""" + x\r + 5;\r + seven\r + 5;\r + eight\r + 0;\r + \r + """); + + String rawResponse = localEndPoint.getResponse(); + // System.err.println(rawResponse); + HttpTester.Response response = HttpTester.parseResponse(rawResponse); + assertEquals(response.getStatus(), HttpStatus.OK_200); + localEndPoint.close(); + + assertThat(chunks.size(), greaterThan(8)); + // chunks.forEach(System.err::println); + + // test all chunks are backed by the same buffer + Content.Chunk firstChunk = chunks.peek(); + assertNotNull(firstChunk); + String backing = firstChunk.toString().replaceFirst("WithRetainable.*ReservedBuffer", "ReservedBuffer").replaceFirst("\\[.*", ""); + for (Content.Chunk chunk : chunks) + if (chunk.hasRemaining()) + assertThat(chunk.toString(), containsString(backing)); + } } diff --git a/jetty-core/jetty-server/src/test/java/org/eclipse/jetty/server/ReadWriteFailuresTest.java b/jetty-core/jetty-server/src/test/java/org/eclipse/jetty/server/ReadWriteFailuresTest.java index f5650926ef68..898099694816 100644 --- a/jetty-core/jetty-server/src/test/java/org/eclipse/jetty/server/ReadWriteFailuresTest.java +++ b/jetty-core/jetty-server/src/test/java/org/eclipse/jetty/server/ReadWriteFailuresTest.java @@ -93,7 +93,7 @@ public boolean handle(Request request, Response response, Callback callback) POST / HTTP/1.1 Host: localhost Content-Length: 1 - + """; HttpTester.Response response = HttpTester.parseResponse(connector.getResponse(request, 5, TimeUnit.SECONDS)); diff --git a/jetty-core/jetty-server/src/test/java/org/eclipse/jetty/server/ThreadStarvationTest.java b/jetty-core/jetty-server/src/test/java/org/eclipse/jetty/server/ThreadStarvationTest.java index 4f4dde261362..373c25fe579a 100644 --- a/jetty-core/jetty-server/src/test/java/org/eclipse/jetty/server/ThreadStarvationTest.java +++ b/jetty-core/jetty-server/src/test/java/org/eclipse/jetty/server/ThreadStarvationTest.java @@ -36,7 +36,7 @@ import org.eclipse.jetty.http.MultiPartFormData; import org.eclipse.jetty.io.ArrayByteBufferPool; import org.eclipse.jetty.io.Content; -import org.eclipse.jetty.server.handler.DelayedHandler; +import org.eclipse.jetty.server.handler.EagerContentHandler; import org.eclipse.jetty.toolchain.test.MavenTestingUtils; import org.eclipse.jetty.util.BufferUtil; import org.eclipse.jetty.util.Callback; @@ -154,7 +154,7 @@ private void prepareServer(Scenario scenario, Handler handler) if (scenario.delayed) { _connector.getConnectionFactory(HttpConnectionFactory.class).getHttpConfiguration().setDelayDispatchUntilContent(true); - _server.insertHandler(new DelayedHandler()); + _server.insertHandler(new EagerContentHandler()); } } diff --git a/jetty-core/jetty-server/src/test/java/org/eclipse/jetty/server/handler/DumpHandler.java b/jetty-core/jetty-server/src/test/java/org/eclipse/jetty/server/handler/DumpHandler.java index 8e4037113556..bf53bc6c895a 100644 --- a/jetty-core/jetty-server/src/test/java/org/eclipse/jetty/server/handler/DumpHandler.java +++ b/jetty-core/jetty-server/src/test/java/org/eclipse/jetty/server/handler/DumpHandler.java @@ -32,6 +32,7 @@ import org.eclipse.jetty.util.Callback; import org.eclipse.jetty.util.Fields; import org.eclipse.jetty.util.Utf8StringBuilder; +import org.eclipse.jetty.util.statistic.CounterStatistic; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -46,6 +47,7 @@ public class DumpHandler extends Handler.Abstract private final Blocker.Shared _blocker = new Blocker.Shared(); private final String _label; + private final CounterStatistic _handled = new CounterStatistic(); public DumpHandler() { @@ -57,163 +59,176 @@ public DumpHandler(String label) _label = label; } + public CounterStatistic getHandledCounter() + { + return _handled; + } + @Override public boolean handle(Request request, Response response, Callback callback) throws Exception { - if (LOG.isDebugEnabled()) - LOG.debug("dump {}", request); - HttpURI httpURI = request.getHttpURI(); + try + { + _handled.increment(); + if (LOG.isDebugEnabled()) + LOG.debug("dump {}", request); + HttpURI httpURI = request.getHttpURI(); - Fields params = Request.extractQueryParameters(request); + Fields params = Request.extractQueryParameters(request); - if (Boolean.parseBoolean(params.getValue("flush"))) - { - try (Blocker.Callback blocker = _blocker.callback()) + if (Boolean.parseBoolean(params.getValue("flush"))) { - response.write(false, null, blocker); - blocker.block(); + try (Blocker.Callback blocker = _blocker.callback()) + { + response.write(false, null, blocker); + blocker.block(); + } } - } - - if (Boolean.parseBoolean(params.getValue("empty"))) - { - response.setStatus(200); - callback.succeeded(); - return true; - } - Utf8StringBuilder read = null; - if (params.getValue("read") != null) - { - read = new Utf8StringBuilder(); - int len = Integer.parseInt(params.getValue("read")); - byte[] buffer = new byte[8192]; + if (Boolean.parseBoolean(params.getValue("empty"))) + { + response.setStatus(200); + callback.succeeded(); + return true; + } - Content.Chunk chunk = null; - while (len > 0) + Utf8StringBuilder read = null; + if (params.getValue("read") != null) { - if (chunk == null) + read = new Utf8StringBuilder(); + int len = Integer.parseInt(params.getValue("read")); + byte[] buffer = new byte[8192]; + + Content.Chunk chunk = null; + while (len > 0) { - chunk = request.read(); if (chunk == null) { - try (Blocker.Runnable blocker = _blocker.runnable()) + chunk = request.read(); + if (chunk == null) { - request.demand(blocker); - blocker.block(); + try (Blocker.Runnable blocker = _blocker.runnable()) + { + request.demand(blocker); + blocker.block(); + } + continue; } - continue; } - } - if (Content.Chunk.isFailure(chunk)) - { - callback.failed(chunk.getFailure()); - return true; - } + if (Content.Chunk.isFailure(chunk)) + { + callback.failed(chunk.getFailure()); + return true; + } - int l = Math.min(buffer.length, Math.min(len, chunk.remaining())); - int r = chunk.get(buffer, 0, l); - read.append(buffer, 0, r); - len -= r; + int l = Math.min(buffer.length, Math.min(len, chunk.remaining())); + int r = chunk.get(buffer, 0, l); + read.append(buffer, 0, r); + len -= r; - if (!chunk.hasRemaining()) - { - boolean last = chunk.isLast(); - chunk.release(); - chunk = null; - if (last) - break; + if (!chunk.hasRemaining()) + { + boolean last = chunk.isLast(); + chunk.release(); + chunk = null; + if (last) + break; + } } + if (chunk != null) + chunk.release(); } - if (chunk != null) - chunk.release(); - } - if (params.getValue("date") != null) - response.getHeaders().put("Date", params.getValue("date")); + if (params.getValue("date") != null) + response.getHeaders().put("Date", params.getValue("date")); - if (params.getValue("ISE") != null) - throw new IllegalStateException("Testing ISE"); + if (params.getValue("ISE") != null) + throw new IllegalStateException("Testing ISE"); - if (params.getValue("error") != null) - { - response.setStatus(Integer.parseInt(params.getValue("error"))); - callback.succeeded(); - return true; - } + if (params.getValue("error") != null) + { + response.setStatus(Integer.parseInt(params.getValue("error"))); + callback.succeeded(); + return true; + } - response.getHeaders().put(HttpHeader.CONTENT_TYPE, MimeTypes.Type.TEXT_HTML.asString()); - - ByteArrayOutputStream buf = new ByteArrayOutputStream(2048); - Writer writer = new OutputStreamWriter(buf, StandardCharsets.ISO_8859_1); - writer.write("

" + _label + "

\n"); - writer.write("
httpURI=" + httpURI + "

\n"); - writer.write("
httpURI.path=" + httpURI.getPath() + "

\n"); - writer.write("
httpURI.query=" + httpURI.getQuery() + "

\n"); - writer.write("
httpURI.pathQuery=" + httpURI.getPathQuery() + "

\n"); - writer.write("
locales=" + Request.getLocales(request).stream().map(Locale::toLanguageTag).toList() + "

\n"); - writer.write("
pathInContext=" + Request.getPathInContext(request) + "

\n"); - writer.write("
contentType=" + request.getHeaders().get(HttpHeader.CONTENT_TYPE) + "

\n"); - writer.write("
servername=" + Request.getServerName(request) + "

\n"); - writer.write("
local=" + Request.getLocalAddr(request) + ":" + Request.getLocalPort(request) + "

\n"); - writer.write("
remote=" + Request.getRemoteAddr(request) + ":" + Request.getRemotePort(request) + "

\n"); - writer.write("

Header:

");
-        writer.write(String.format("%4s %s %s\n", request.getMethod(), httpURI.getPathQuery(), request.getConnectionMetaData().getProtocol()));
-        for (HttpField field : request.getHeaders())
-        {
-            String name = field.getName();
-            writer.write(name);
-            writer.write(": ");
-            String value = field.getValue();
-            writer.write(value == null ? "" : value);
-            writer.write("\n");
-        }
+            response.getHeaders().put(HttpHeader.CONTENT_TYPE, MimeTypes.Type.TEXT_HTML.asString());
+
+            ByteArrayOutputStream buf = new ByteArrayOutputStream(2048);
+            Writer writer = new OutputStreamWriter(buf, StandardCharsets.ISO_8859_1);
+            writer.write("

" + _label + "

\n"); + writer.write("
httpURI=" + httpURI + "

\n"); + writer.write("
httpURI.path=" + httpURI.getPath() + "

\n"); + writer.write("
httpURI.query=" + httpURI.getQuery() + "

\n"); + writer.write("
httpURI.pathQuery=" + httpURI.getPathQuery() + "

\n"); + writer.write("
locales=" + Request.getLocales(request).stream().map(Locale::toLanguageTag).toList() + "

\n"); + writer.write("
pathInContext=" + Request.getPathInContext(request) + "

\n"); + writer.write("
contentType=" + request.getHeaders().get(HttpHeader.CONTENT_TYPE) + "

\n"); + writer.write("
servername=" + Request.getServerName(request) + "

\n"); + writer.write("
local=" + Request.getLocalAddr(request) + ":" + Request.getLocalPort(request) + "

\n"); + writer.write("
remote=" + Request.getRemoteAddr(request) + ":" + Request.getRemotePort(request) + "

\n"); + writer.write("

Header:

");
+            writer.write(String.format("%4s %s %s\n", request.getMethod(), httpURI.getPathQuery(), request.getConnectionMetaData().getProtocol()));
+            for (HttpField field : request.getHeaders())
+            {
+                String name = field.getName();
+                writer.write(name);
+                writer.write(": ");
+                String value = field.getValue();
+                writer.write(value == null ? "" : value);
+                writer.write("\n");
+            }
 
-        writer.write("
\n

Attributes:

\n
");
-        for (String attr : request.getAttributeNameSet())
-        {
-            writer.write(attr);
-            writer.write("=");
-            writer.write(request.getAttribute(attr).toString());
-            writer.write("\n");
-        }
+            writer.write("
\n

Attributes:

\n
");
+            for (String attr : request.getAttributeNameSet())
+            {
+                writer.write(attr);
+                writer.write("=");
+                writer.write(request.getAttribute(attr).toString());
+                writer.write("\n");
+            }
 
-        writer.write("
\n

Content:

\n
");
-        if (read != null)
-            writer.write(read.toCompleteString());
-        else
-            writer.write(Content.Source.asString(request));
+            writer.write("
\n

Content:

\n
");
+            if (read != null)
+                writer.write(read.toCompleteString());
+            else
+                writer.write(Content.Source.asString(request));
 
-        writer.write("
\n"); - writer.write("\n"); - writer.flush(); + writer.write("
\n"); + writer.write("\n"); + writer.flush(); - // commit now - if (!Boolean.parseBoolean(params.getValue("no-content-length"))) - response.getHeaders().put(HttpHeader.CONTENT_LENGTH, buf.size() + 1000); + // commit now + if (!Boolean.parseBoolean(params.getValue("no-content-length"))) + response.getHeaders().put(HttpHeader.CONTENT_LENGTH, buf.size() + 1000); - response.getHeaders().add("Before-Flush", response.isCommitted() ? "Committed???" : "Not Committed"); + response.getHeaders().add("Before-Flush", response.isCommitted() ? "Committed???" : "Not Committed"); - try (Blocker.Callback blocker = _blocker.callback()) - { - response.write(false, BufferUtil.toBuffer(buf.toByteArray()), blocker); - blocker.block(); - } - response.getHeaders().add("After-Flush", "These headers should not be seen in the response!!!"); - String value = response.isCommitted() ? "Committed" : "Not Committed?"; - response.getHeaders().add("After-Flush", value); + try (Blocker.Callback blocker = _blocker.callback()) + { + response.write(false, BufferUtil.toBuffer(buf.toByteArray()), blocker); + blocker.block(); + } + response.getHeaders().add("After-Flush", "These headers should not be seen in the response!!!"); + String value = response.isCommitted() ? "Committed" : "Not Committed?"; + response.getHeaders().add("After-Flush", value); + + // write remaining content after commit + String padding = "ABCDEFGHIJ".repeat(99) + "ABCDEFGH\r\n"; - // write remaining content after commit - String padding = "ABCDEFGHIJ".repeat(99) + "ABCDEFGH\r\n"; + try (Blocker.Callback blocker = _blocker.callback()) + { + response.write(true, BufferUtil.toBuffer(padding.getBytes(StandardCharsets.ISO_8859_1)), blocker); + blocker.block(); + } - try (Blocker.Callback blocker = _blocker.callback()) + callback.succeeded(); + return true; + } + finally { - response.write(true, BufferUtil.toBuffer(padding.getBytes(StandardCharsets.ISO_8859_1)), blocker); - blocker.block(); + _handled.decrement(); } - - callback.succeeded(); - return true; } } diff --git a/jetty-core/jetty-server/src/test/java/org/eclipse/jetty/server/handler/DelayedHandlerTest.java b/jetty-core/jetty-server/src/test/java/org/eclipse/jetty/server/handler/EagerContentHandlerTest.java similarity index 74% rename from jetty-core/jetty-server/src/test/java/org/eclipse/jetty/server/handler/DelayedHandlerTest.java rename to jetty-core/jetty-server/src/test/java/org/eclipse/jetty/server/handler/EagerContentHandlerTest.java index 030f011f5028..959e52afa6e3 100644 --- a/jetty-core/jetty-server/src/test/java/org/eclipse/jetty/server/handler/DelayedHandlerTest.java +++ b/jetty-core/jetty-server/src/test/java/org/eclipse/jetty/server/handler/EagerContentHandlerTest.java @@ -46,6 +46,7 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.not; import static org.hamcrest.Matchers.sameInstance; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -53,7 +54,7 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; -public class DelayedHandlerTest +public class EagerContentHandlerTest { private Server _server; private ServerConnector _connector; @@ -73,19 +74,11 @@ public void after() throws Exception } @Test - public void testNotDelayed() throws Exception + public void testNotEager() throws Exception { - DelayedHandler delayedHandler = new DelayedHandler() - { - @Override - protected DelayedProcess newDelayedProcess(boolean contentExpected, String contentType, MimeTypes.Type mimeType, Handler handler, Request request, Response response, Callback callback) - { - return null; - } - }; - - _server.setHandler(delayedHandler); - delayedHandler.setHandler(new HelloHandler()); + EagerContentHandler eagerContentHandler = new EagerContentHandler(new HelloHandler(), new EagerContentHandler.ContentLoaderFactory[]{}); + _server.setHandler(eagerContentHandler); + eagerContentHandler.setHandler(new HelloHandler()); _server.start(); try (Socket socket = new Socket("localhost", _connector.getLocalPort())) @@ -109,32 +102,40 @@ protected DelayedProcess newDelayedProcess(boolean contentExpected, String conte } @Test - public void testDelayed() throws Exception + public void testEager() throws Exception { Exchanger handleEx = new Exchanger<>(); - DelayedHandler delayedHandler = new DelayedHandler() - { - @Override - protected DelayedProcess newDelayedProcess(boolean contentExpected, String contentType, MimeTypes.Type mimeType, Handler handler, Request request, Response response, Callback callback) + EagerContentHandler eagerContentHandler = new EagerContentHandler(new HelloHandler(), + new EagerContentHandler.ContentLoaderFactory() { - return new DelayedProcess(handler, request, response, callback) + @Override + public String getApplicableMimeType() { - @Override - protected void delay() throws Exception + return null; + } + + @Override + public EagerContentHandler.ContentLoader newContentLoader(String contentType, String mimeType, Handler handler, Request request, Response response, Callback callback) + { + return new EagerContentHandler.ContentLoader(handler, request, response, callback) { - handleEx.exchange(this::process); - } - }; - } - }; + @Override + protected void load() throws Exception + { + handleEx.exchange(this::handle); + } + }; + } + }); - _server.setHandler(delayedHandler); + _server.setHandler(eagerContentHandler); CountDownLatch processing = new CountDownLatch(1); - delayedHandler.setHandler(new HelloHandler() + eagerContentHandler.setHandler(new HelloHandler() { @Override public boolean handle(Request request, Response response, Callback callback) throws Exception { + assertThat(Content.Source.asString(request), is("0123456789")); processing.countDown(); return super.handle(request, response, callback); } @@ -144,9 +145,11 @@ public boolean handle(Request request, Response response, Callback callback) thr try (Socket socket = new Socket("localhost", _connector.getLocalPort())) { String request = """ - GET / HTTP/1.1\r + POST / HTTP/1.1\r Host: localhost\r + Content-Length: 10\r \r + 0123456789\r """; OutputStream output = socket.getOutputStream(); output.write(request.getBytes(StandardCharsets.UTF_8)); @@ -170,13 +173,13 @@ public boolean handle(Request request, Response response, Callback callback) thr } @Test - public void testDelayedUntilContent() throws Exception + public void testEagerRetainedContent() throws Exception { - DelayedHandler delayedHandler = new DelayedHandler(); + EagerContentHandler eagerContentHandler = new EagerContentHandler(new EagerContentHandler.RetainedContentLoaderFactory(-1, -1, true)); - _server.setHandler(delayedHandler); + _server.setHandler(eagerContentHandler); CountDownLatch processing = new CountDownLatch(1); - delayedHandler.setHandler(new HelloHandler() + eagerContentHandler.setHandler(new HelloHandler() { @Override public boolean handle(Request request, Response response, Callback callback) throws Exception @@ -187,8 +190,8 @@ public boolean handle(Request request, Response response, Callback callback) thr String stack = out.toString(StandardCharsets.ISO_8859_1); assertThat(stack, not(containsString("DemandContentCallback.succeeded"))); assertThat(stack, not(containsString("%s.%s".formatted( - DelayedHandler.UntilContentDelayedProcess.class.getSimpleName(), - DelayedHandler.UntilContentDelayedProcess.class.getMethod("onContent").getName())))); + EagerContentHandler.RetainedContentLoaderFactory.RetainedContentLoader.class.getSimpleName(), + EagerContentHandler.RetainedContentLoaderFactory.RetainedContentLoader.class.getDeclaredMethod("run").getName())))); processing.countDown(); return super.handle(request, response, callback); @@ -225,15 +228,15 @@ public boolean handle(Request request, Response response, Callback callback) thr } @Test - public void testDelayedUntilContentInContext() throws Exception + public void testEagerContentInContext() throws Exception { ContextHandler context = new ContextHandler(); _server.setHandler(context); - DelayedHandler delayedHandler = new DelayedHandler(); - context.setHandler(delayedHandler); + EagerContentHandler eagerContentHandler = new EagerContentHandler(); + context.setHandler(eagerContentHandler); CountDownLatch processing = new CountDownLatch(1); - delayedHandler.setHandler(new HelloHandler() + eagerContentHandler.setHandler(new HelloHandler() { @Override public boolean handle(Request request, Response response, Callback callback) throws Exception @@ -244,8 +247,12 @@ public boolean handle(Request request, Response response, Callback callback) thr String stack = out.toString(StandardCharsets.ISO_8859_1); assertThat(stack, not(containsString("DemandContentCallback.succeeded"))); assertThat(stack, not(containsString("%s.%s".formatted( - DelayedHandler.UntilContentDelayedProcess.class.getSimpleName(), - DelayedHandler.UntilContentDelayedProcess.class.getMethod("onContent").getName())))); + EagerContentHandler.RetainedContentLoaderFactory.RetainedContentLoader.class.getSimpleName(), + EagerContentHandler.RetainedContentLoaderFactory.RetainedContentLoader.class.getDeclaredMethod("run").getName())))); + + // Check content + String body = Content.Source.asString(request, StandardCharsets.ISO_8859_1); + assertThat(body, is("0123456789")); // Check the thread is in the context assertThat(ContextHandler.getCurrentContext(), sameInstance(context.getContext())); @@ -273,7 +280,12 @@ public boolean handle(Request request, Response response, Callback callback) thr assertFalse(processing.await(250, TimeUnit.MILLISECONDS)); - output.write("01234567\r\n".getBytes(StandardCharsets.UTF_8)); + output.write("0123456".getBytes(StandardCharsets.UTF_8)); + output.flush(); + + assertFalse(processing.await(250, TimeUnit.MILLISECONDS)); + + output.write("789".getBytes(StandardCharsets.UTF_8)); output.flush(); assertTrue(processing.await(10, TimeUnit.SECONDS)); @@ -288,12 +300,12 @@ public boolean handle(Request request, Response response, Callback callback) thr } @Test - public void testNoDelayWithContent() throws Exception + public void testDirectCallWithContent() throws Exception { - DelayedHandler delayedHandler = new DelayedHandler(); + EagerContentHandler eagerContentHandler = new EagerContentHandler(); - _server.setHandler(delayedHandler); - delayedHandler.setHandler(new HelloHandler() + _server.setHandler(eagerContentHandler); + eagerContentHandler.setHandler(new HelloHandler() { @Override public boolean handle(Request request, Response response, Callback callback) throws Exception @@ -303,7 +315,6 @@ public boolean handle(Request request, Response response, Callback callback) thr new Throwable().printStackTrace(new PrintStream(out)); String stack = out.toString(StandardCharsets.ISO_8859_1); assertThat(stack, containsString("org.eclipse.jetty.server.internal.HttpConnection.onFillable")); - assertThat(stack, containsString("org.eclipse.jetty.server.handler.DelayedHandler.handle")); // Check the content is available String content = Content.Source.asString(request); @@ -337,26 +348,87 @@ public boolean handle(Request request, Response response, Callback callback) thr } @Test - public void testDelayed404() throws Exception + public void testDirectCallWithChunkedContent() throws Exception + { + EagerContentHandler eagerContentHandler = new EagerContentHandler(); + + _server.setHandler(eagerContentHandler); + eagerContentHandler.setHandler(new HelloHandler() + { + @Override + public boolean handle(Request request, Response response, Callback callback) throws Exception + { + // Check that we are called directly from HttpConnection.onFillable + ByteArrayOutputStream out = new ByteArrayOutputStream(8192); + new Throwable().printStackTrace(new PrintStream(out)); + String stack = out.toString(StandardCharsets.ISO_8859_1); + assertThat(stack, containsString("org.eclipse.jetty.server.internal.HttpConnection.onFillable")); + + // Check the content is available + String content = Content.Source.asString(request); + assertThat(content, equalTo("1234567890")); + + return super.handle(request, response, callback); + } + }); + _server.start(); + + try (Socket socket = new Socket("localhost", _connector.getLocalPort())) + { + String request = """ + POST / HTTP/1.1\r + Host: localhost\r + Transfer-Encoding: chunked\r + \r + 3;\r + 123\r + 4;\r + 4567\r + 3;\r + 890\r + 0;\r + \r + """; + OutputStream output = socket.getOutputStream(); + output.write(request.getBytes(StandardCharsets.UTF_8)); + output.flush(); + + HttpTester.Input input = HttpTester.from(socket.getInputStream()); + HttpTester.Response response = HttpTester.parseResponse(input); + assertNotNull(response); + assertEquals(HttpStatus.OK_200, response.getStatus()); + String content = new String(response.getContentBytes(), StandardCharsets.UTF_8); + assertThat(content, containsString("Hello")); + } + } + + @Test + public void testEager404() throws Exception { - DelayedHandler delayedHandler = new DelayedHandler() + EagerContentHandler eagerContentHandler = new EagerContentHandler(new EagerContentHandler.ContentLoaderFactory() { @Override - protected DelayedProcess newDelayedProcess(boolean contentExpected, String contentType, MimeTypes.Type mimeType, Handler handler, Request request, Response response, Callback callback) + public String getApplicableMimeType() { - return new DelayedProcess(handler, request, response, callback) + return null; + } + + @Override + public EagerContentHandler.ContentLoader newContentLoader(String contentType, String mimeType, Handler handler, Request request, Response response, Callback callback) + { + return new EagerContentHandler.ContentLoader(handler, request, response, callback) { @Override - protected void delay() + protected void load() { - getRequest().getContext().execute(this::process); + getRequest().getContext().execute(this::handle); } }; } - }; + }); - _server.setHandler(delayedHandler); - delayedHandler.setHandler(new Handler.Abstract() + _server.setHandler(eagerContentHandler); + eagerContentHandler.setHandler(new Handler.Abstract() { @Override public boolean handle(Request request, Response response, Callback callback) @@ -388,13 +460,13 @@ public boolean handle(Request request, Response response, Callback callback) } @Test - public void testDelayedFormFields() throws Exception + public void testEagerFormFields() throws Exception { - DelayedHandler delayedHandler = new DelayedHandler(); + EagerContentHandler eagerContentHandler = new EagerContentHandler(); - _server.setHandler(delayedHandler); + _server.setHandler(eagerContentHandler); CountDownLatch processing = new CountDownLatch(2); - delayedHandler.setHandler(new Handler.Abstract() + eagerContentHandler.setHandler(new Handler.Abstract() { @Override public boolean handle(Request request, Response response, Callback callback) throws Exception @@ -459,22 +531,21 @@ public boolean handle(Request request, Response response, Callback callback) thr } @Test - public void testNoDelayFormFields() throws Exception + public void testDirectCallFormFields() throws Exception { - DelayedHandler delayedHandler = new DelayedHandler(); + EagerContentHandler eagerContentHandler = new EagerContentHandler(); - _server.setHandler(delayedHandler); - delayedHandler.setHandler(new Handler.Abstract() + _server.setHandler(eagerContentHandler); + eagerContentHandler.setHandler(new Handler.Abstract() { @Override public boolean handle(Request request, Response response, Callback callback) throws Exception { - // Check that we are called directly from HttpConnection.onFillable via DelayedHandler.handle(). + // Check that we are called directly from HttpConnection.onFillable via EagerHandler.handle(). ByteArrayOutputStream out = new ByteArrayOutputStream(8192); new Throwable().printStackTrace(new PrintStream(out)); String stack = out.toString(StandardCharsets.ISO_8859_1); assertThat(stack, containsString("org.eclipse.jetty.server.internal.HttpConnection.onFillable")); - assertThat(stack, containsString("org.eclipse.jetty.server.handler.DelayedHandler.handle")); Fields fields = FormFields.getFields(request); Content.Sink.write(response, true, String.valueOf(fields), callback); @@ -509,12 +580,12 @@ public boolean handle(Request request, Response response, Callback callback) thr } @Test - public void testDelayedMultipart() throws Exception + public void testEagerMultipart() throws Exception { - DelayedHandler delayedHandler = new DelayedHandler(); + EagerContentHandler eagerContentHandler = new EagerContentHandler(); _server.setAttribute(MultiPartConfig.class.getName(), new MultiPartConfig.Builder().build()); - _server.setHandler(delayedHandler); - delayedHandler.setHandler(new Handler.Abstract() + _server.setHandler(eagerContentHandler); + eagerContentHandler.setHandler(new Handler.Abstract() { @Override public boolean handle(Request request, Response response, Callback callback) throws Exception diff --git a/jetty-core/jetty-server/src/test/java/org/eclipse/jetty/server/jmh/HandlerBenchmark.java b/jetty-core/jetty-server/src/test/java/org/eclipse/jetty/server/jmh/HandlerBenchmark.java index f193e0a597b0..05e12345f73a 100644 --- a/jetty-core/jetty-server/src/test/java/org/eclipse/jetty/server/jmh/HandlerBenchmark.java +++ b/jetty-core/jetty-server/src/test/java/org/eclipse/jetty/server/jmh/HandlerBenchmark.java @@ -25,7 +25,7 @@ import org.eclipse.jetty.server.ServerConnector; import org.eclipse.jetty.server.handler.ContextHandler; import org.eclipse.jetty.server.handler.ContextHandlerCollection; -import org.eclipse.jetty.server.handler.DelayedHandler; +import org.eclipse.jetty.server.handler.EagerContentHandler; import org.eclipse.jetty.server.handler.EchoHandler; import org.eclipse.jetty.util.StringUtil; import org.openjdk.jmh.annotations.Benchmark; @@ -83,10 +83,10 @@ public static void setupServer() throws Exception { _server.addConnector(_connector); _connector.getConnectionFactory(HttpConnectionFactory.class).getHttpConfiguration().addCustomizer(new ForwardedRequestCustomizer()); - DelayedHandler delayedHandler = new DelayedHandler(); - _server.setHandler(delayedHandler); + EagerContentHandler eagerContentHandler = new EagerContentHandler(); + _server.setHandler(eagerContentHandler); ContextHandlerCollection contexts = new ContextHandlerCollection(); - delayedHandler.setHandler(contexts); + eagerContentHandler.setHandler(contexts); ContextHandler context = new ContextHandler("/ctx"); contexts.addHandler(context); EchoHandler echo = new EchoHandler(); diff --git a/jetty-core/jetty-tests/jetty-test-client-transports/src/test/java/org/eclipse/jetty/test/client/transport/AbstractTest.java b/jetty-core/jetty-tests/jetty-test-client-transports/src/test/java/org/eclipse/jetty/test/client/transport/AbstractTest.java index 0d61eb3cac6b..d706fe6f0df3 100644 --- a/jetty-core/jetty-tests/jetty-test-client-transports/src/test/java/org/eclipse/jetty/test/client/transport/AbstractTest.java +++ b/jetty-core/jetty-tests/jetty-test-client-transports/src/test/java/org/eclipse/jetty/test/client/transport/AbstractTest.java @@ -127,6 +127,8 @@ public static Collection transportsTLS() @AfterEach public void dispose(TestInfo testInfo) throws Exception { + // Stop the client so that all connections are closed and any saved buffers are released + LifeCycle.stop(client); try { if (serverBufferPool != null && !isLeakTrackingDisabled(testInfo, "server")) @@ -136,7 +138,7 @@ public void dispose(TestInfo testInfo) throws Exception } finally { - stop(); + LifeCycle.stop(server); } } diff --git a/jetty-core/jetty-tests/jetty-test-client-transports/src/test/java/org/eclipse/jetty/test/client/transport/ServerRetainContentTest.java b/jetty-core/jetty-tests/jetty-test-client-transports/src/test/java/org/eclipse/jetty/test/client/transport/ServerRetainContentTest.java new file mode 100644 index 000000000000..17061e30ca65 --- /dev/null +++ b/jetty-core/jetty-tests/jetty-test-client-transports/src/test/java/org/eclipse/jetty/test/client/transport/ServerRetainContentTest.java @@ -0,0 +1,136 @@ +// +// ======================================================================== +// Copyright (c) 1995 Mort Bay Consulting Pty Ltd and others. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 +// which is available at https://www.apache.org/licenses/LICENSE-2.0. +// +// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 +// ======================================================================== +// + +package org.eclipse.jetty.test.client.transport; + +import java.io.IOException; +import java.util.Queue; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import org.eclipse.jetty.client.AsyncRequestContent; +import org.eclipse.jetty.http.HttpStatus; +import org.eclipse.jetty.io.ArrayByteBufferPool; +import org.eclipse.jetty.io.Content; +import org.eclipse.jetty.server.Handler; +import org.eclipse.jetty.server.Request; +import org.eclipse.jetty.server.Response; +import org.eclipse.jetty.util.Blocker; +import org.eclipse.jetty.util.BufferUtil; +import org.eclipse.jetty.util.Callback; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.lessThanOrEqualTo; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class ServerRetainContentTest extends AbstractTest +{ + @ParameterizedTest + @MethodSource("transportsNoFCGI") + public void testRetainPOST(Transport transport) throws Exception + { + Queue chunks = new ConcurrentLinkedQueue<>(); + CountDownLatch blocked = new CountDownLatch(1); + + start(transport, new Handler.Abstract() + { + @Override + public boolean handle(Request request, Response response, Callback callback) + { + while (true) + { + Content.Chunk chunk = request.read(); + if (chunk == null) + { + try (Blocker.Runnable blocker = Blocker.runnable()) + { + blocked.countDown(); + request.demand(blocker); + blocker.block(); + } + catch (IOException e) + { + // ignored + } + continue; + } + + chunks.add(chunk); + if (chunk.isLast()) + break; + } + callback.succeeded(); + return true; + } + }); + AsyncRequestContent content = new AsyncRequestContent(); + + Callback.Completable one = new Callback.Completable(); + content.write(false, BufferUtil.toBuffer("1"), one); + + ArrayByteBufferPool byteBufferPool = (ArrayByteBufferPool)server.getByteBufferPool(); + + long baseMemory = byteBufferPool.getDirectMemory() + byteBufferPool.getHeapMemory() + byteBufferPool.getReserved(); + + CountDownLatch latch = new CountDownLatch(1); + client.newRequest(newURI(transport)) + .method("POST") + .body(content) + .send(result -> + { + assertThat(result.getResponse().getStatus(), is(HttpStatus.OK_200)); + latch.countDown(); + }); + + Callback.Completable two = new Callback.Completable(); + content.write(false, BufferUtil.toBuffer("2"), two); + content.flush(); + + assertTrue(blocked.await(5, TimeUnit.SECONDS)); + one.get(5, TimeUnit.SECONDS); + two.get(5, TimeUnit.SECONDS); + + final int CHUNKS = 1000; + for (int i = 3; i < CHUNKS; i++) + { + Callback.Completable complete = new Callback.Completable(); + content.write(false, BufferUtil.toBuffer(Integer.toString(i)), complete); + content.flush(); + complete.get(5, TimeUnit.SECONDS); + } + + Callback.Completable end = new Callback.Completable(); + content.write(true, BufferUtil.toBuffer("x"), end); + content.close(); + end.get(5, TimeUnit.SECONDS); + + assertTrue(latch.await(5, TimeUnit.SECONDS)); + long finalMemory = byteBufferPool.getDirectMemory() + byteBufferPool.getHeapMemory() + byteBufferPool.getReserved(); + + long totalData = 0; + for (Content.Chunk chunk : chunks) + { + chunk.release(); + if (chunk.hasRemaining()) + totalData += chunk.remaining(); + } + + assertThat(finalMemory - baseMemory, lessThanOrEqualTo((transport.isSecure() ? 100 : 32) * 1024L)); + + client.close(); + } +} diff --git a/jetty-core/jetty-xml/src/main/java/org/eclipse/jetty/xml/XmlConfiguration.java b/jetty-core/jetty-xml/src/main/java/org/eclipse/jetty/xml/XmlConfiguration.java index a07ccb5df461..9c45a486319d 100644 --- a/jetty-core/jetty-xml/src/main/java/org/eclipse/jetty/xml/XmlConfiguration.java +++ b/jetty-core/jetty-xml/src/main/java/org/eclipse/jetty/xml/XmlConfiguration.java @@ -614,7 +614,7 @@ private void set(Object obj, XmlParser.Node node) throws Exception String setter = "set" + name.substring(0, 1).toUpperCase(Locale.ENGLISH) + name.substring(1); String id = node.getAttribute("id"); String property = node.getAttribute("property"); - String propertyValue = null; + Object propertyValue = null; Class oClass = nodeClass(node); if (oClass == null) @@ -630,6 +630,7 @@ private void set(Object obj, XmlParser.Node node) throws Exception { // check that there is at least one setter or field that could have matched if (Arrays.stream(oClass.getMethods()).noneMatch(m -> m.getName().equals(setter)) && + Arrays.stream(oClass.getMethods()).noneMatch(m -> m.getName().equals(name)) && Arrays.stream(oClass.getFields()).filter(f -> Modifier.isPublic(f.getModifiers())).noneMatch(f -> f.getName().equals(name))) { NoSuchMethodException e = new NoSuchMethodException(String.format("No method '%s' on %s", setter, oClass.getName())); @@ -639,6 +640,8 @@ private void set(Object obj, XmlParser.Node node) throws Exception // otherwise it is a noop return; } + + propertyValue = toType(propertyValue, node.getAttribute("type")); } Object value = value(obj, node); @@ -676,8 +679,8 @@ private void set(Object obj, XmlParser.Node node) throws Exception try { Field type = vClass.getField("TYPE"); - vClass = (Class)type.get(null); - Method set = oClass.getMethod(setter, vClass); + Class nClass = (Class)type.get(null); + Method set = oClass.getMethod(setter, nClass); invokeMethod(set, obj, arg); return; } @@ -687,6 +690,40 @@ private void set(Object obj, XmlParser.Node node) throws Exception errors.add(e); } + // Try a builder + try + { + Method builder = oClass.getMethod(name, vClass); + if (builder.getReturnType() == oClass) + { + invokeMethod(builder, obj, arg); + return; + } + } + catch (IllegalArgumentException | IllegalAccessException | NoSuchMethodException e) + { + LOG.trace("IGNORED", e); + errors.add(e); + } + + // Try for native builder + try + { + Field type = vClass.getField("TYPE"); + Class nClass = (Class)type.get(null); + Method builder = oClass.getMethod(name, nClass); + if (builder.getReturnType() == oClass) + { + invokeMethod(builder, obj, arg); + return; + } + } + catch (NoSuchFieldException | IllegalArgumentException | IllegalAccessException | NoSuchMethodException e) + { + LOG.trace("IGNORED", e); + errors.add(e); + } + // Try a field try { @@ -1509,6 +1546,11 @@ private Object value(Object obj, XmlParser.Node node) throws Exception } } + return toType(value, type); + } + + private Object toType(Object value, String type) throws Exception + { // No value if (value == null) { diff --git a/jetty-core/jetty-xml/src/test/java/org/eclipse/jetty/xml/ExampleConfiguration.java b/jetty-core/jetty-xml/src/test/java/org/eclipse/jetty/xml/ExampleConfiguration.java index 6ee1fbc29c13..b1f3babb25c8 100644 --- a/jetty-core/jetty-xml/src/test/java/org/eclipse/jetty/xml/ExampleConfiguration.java +++ b/jetty-core/jetty-xml/src/test/java/org/eclipse/jetty/xml/ExampleConfiguration.java @@ -51,6 +51,7 @@ public class ExampleConfiguration extends HashMap private ConstructorArgTestClass constructorArgTestClass; public Map map; public Double number; + public String builder; public interface TestInterface { @@ -214,4 +215,10 @@ public void setMap(Map map) { this.map = map; } + + public ExampleConfiguration builder(String value) + { + this.builder = value; + return this; + } } diff --git a/jetty-core/jetty-xml/src/test/java/org/eclipse/jetty/xml/XmlConfigurationTest.java b/jetty-core/jetty-xml/src/test/java/org/eclipse/jetty/xml/XmlConfigurationTest.java index a2053277ae18..205d36f15f30 100644 --- a/jetty-core/jetty-xml/src/test/java/org/eclipse/jetty/xml/XmlConfigurationTest.java +++ b/jetty-core/jetty-xml/src/test/java/org/eclipse/jetty/xml/XmlConfigurationTest.java @@ -219,6 +219,8 @@ public void testPassedObject(String configure) throws Exception assertThat(concurrentMap, instanceOf(ConcurrentMap.class)); assertEquals(concurrentMap.get("KEY"), "ITEM"); + assertThat(tc.builder, is("builder")); + if ("org/eclipse/jetty/xml/configureWithElements.xml".equals(configure)) { System.err.println("Static call with TestImpl: " + ExampleConfiguration.calledWithClass); diff --git a/jetty-core/jetty-xml/src/test/resources/org/eclipse/jetty/xml/configureWithAttr.xml b/jetty-core/jetty-xml/src/test/resources/org/eclipse/jetty/xml/configureWithAttr.xml index 90a5e604c028..d7d249e8cc3e 100644 --- a/jetty-core/jetty-xml/src/test/resources/org/eclipse/jetty/xml/configureWithAttr.xml +++ b/jetty-core/jetty-xml/src/test/resources/org/eclipse/jetty/xml/configureWithAttr.xml @@ -141,6 +141,8 @@ + builder + diff --git a/jetty-core/jetty-xml/src/test/resources/org/eclipse/jetty/xml/configureWithElements.xml b/jetty-core/jetty-xml/src/test/resources/org/eclipse/jetty/xml/configureWithElements.xml index ec40d0ba6b00..d492e2d35699 100644 --- a/jetty-core/jetty-xml/src/test/resources/org/eclipse/jetty/xml/configureWithElements.xml +++ b/jetty-core/jetty-xml/src/test/resources/org/eclipse/jetty/xml/configureWithElements.xml @@ -197,6 +197,8 @@ + builder + diff --git a/jetty-demos/jetty-servlet5-demos/jetty-servlet5-demo-simple-webapp/src/main/java/org/eclipse/jetty/demo/simple/EchoServlet.java b/jetty-demos/jetty-servlet5-demos/jetty-servlet5-demo-simple-webapp/src/main/java/org/eclipse/jetty/demo/simple/EchoServlet.java new file mode 100644 index 000000000000..db8ff546c45e --- /dev/null +++ b/jetty-demos/jetty-servlet5-demos/jetty-servlet5-demo-simple-webapp/src/main/java/org/eclipse/jetty/demo/simple/EchoServlet.java @@ -0,0 +1,64 @@ +// +// ======================================================================== +// Copyright (c) 1995 Mort Bay Consulting Pty Ltd and others. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 +// which is available at https://www.apache.org/licenses/LICENSE-2.0. +// +// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 +// ======================================================================== +// + +package org.eclipse.jetty.demo.simple; + +import java.io.IOException; +import java.util.stream.Collectors; + +import jakarta.servlet.MultipartConfigElement; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletInputStream; +import jakarta.servlet.ServletOutputStream; +import jakarta.servlet.annotation.MultipartConfig; +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +@MultipartConfig +public class EchoServlet extends HttpServlet +{ + @Override + protected void service(HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException + { + response.setContentType(request.getContentType()); + ServletOutputStream output = response.getOutputStream(); + String pathInfo = request.getPathInfo(); + switch (pathInfo) + { + case "/form" -> + { + String content = request.getParameterMap().entrySet().stream() + .map(e -> "%s=%s".formatted(e.getKey(), String.join(", ", e.getValue()))) + .collect(Collectors.joining("&")); + output.print(content); + } + case "/multipart" -> + { + MultipartConfigElement config = new MultipartConfigElement(""); + request.setAttribute(MultipartConfigElement.class.getName(), config); + + String content = request.getParts().stream() + .map(part -> "name=%s&length=%d".formatted(part.getName(), part.getSize())) + .collect(Collectors.joining(",")); + output.print(content); + } + default -> + { + ServletInputStream input = request.getInputStream(); + response.setContentLengthLong(request.getContentLengthLong()); + input.transferTo(output); + } + } + } +} diff --git a/jetty-demos/jetty-servlet5-demos/jetty-servlet5-demo-simple-webapp/src/main/webapp/WEB-INF/web.xml b/jetty-demos/jetty-servlet5-demos/jetty-servlet5-demo-simple-webapp/src/main/webapp/WEB-INF/web.xml index 0d5de707679c..4361e90df459 100644 --- a/jetty-demos/jetty-servlet5-demos/jetty-servlet5-demo-simple-webapp/src/main/webapp/WEB-INF/web.xml +++ b/jetty-demos/jetty-servlet5-demos/jetty-servlet5-demo-simple-webapp/src/main/webapp/WEB-INF/web.xml @@ -21,4 +21,13 @@ /hello/* + + echo + org.eclipse.jetty.demo.simple.EchoServlet + + + echo + /echo/* + + diff --git a/jetty-ee10/jetty-ee10-servlet/src/main/java/org/eclipse/jetty/ee10/servlet/EagerFormHandler.java b/jetty-ee10/jetty-ee10-servlet/src/main/java/org/eclipse/jetty/ee10/servlet/EagerFormHandler.java index 4365c3348cfa..bb3528881880 100644 --- a/jetty-ee10/jetty-ee10-servlet/src/main/java/org/eclipse/jetty/ee10/servlet/EagerFormHandler.java +++ b/jetty-ee10/jetty-ee10-servlet/src/main/java/org/eclipse/jetty/ee10/servlet/EagerFormHandler.java @@ -28,7 +28,9 @@ * Handler to eagerly and asynchronously read and parse {@link MimeTypes.Type#FORM_ENCODED} and * {@link MimeTypes.Type#MULTIPART_FORM_DATA} content prior to invoking the {@link ServletHandler}, * which can then consume them with blocking APIs but without blocking. + * @deprecated use {@link org.eclipse.jetty.server.handler.EagerContentHandler} */ +@Deprecated(forRemoval = true, since = "12.1.0") public class EagerFormHandler extends Handler.Wrapper { public EagerFormHandler() diff --git a/jetty-ee10/jetty-ee10-servlet/src/main/java/org/eclipse/jetty/ee10/servlet/ServletApiRequest.java b/jetty-ee10/jetty-ee10-servlet/src/main/java/org/eclipse/jetty/ee10/servlet/ServletApiRequest.java index 462716e81cd1..a4c910dbde61 100644 --- a/jetty-ee10/jetty-ee10-servlet/src/main/java/org/eclipse/jetty/ee10/servlet/ServletApiRequest.java +++ b/jetty-ee10/jetty-ee10-servlet/src/main/java/org/eclipse/jetty/ee10/servlet/ServletApiRequest.java @@ -1268,7 +1268,7 @@ private void extractContentParameters() throws BadMessageException try { int contentLength = getContentLength(); - if (contentLength != 0 && _inputState == ServletContextRequest.INPUT_NONE) + if (contentLength != 0) { String baseType = HttpField.getValueParameters(getContentType(), null); if (MimeTypes.Type.FORM_ENCODED.is(baseType) && @@ -1283,7 +1283,6 @@ private void extractContentParameters() throws BadMessageException } catch (IllegalStateException | IllegalArgumentException | CompletionException e) { - LOG.warn(e.toString()); throw new BadMessageException("Unable to parse form content", e); } } @@ -1321,7 +1320,6 @@ else if (MimeTypes.Type.MULTIPART_FORM_DATA.is(baseType) && } catch (IllegalStateException | IllegalArgumentException | CompletionException e) { - LOG.warn(e.toString()); throw new BadMessageException("Unable to parse form content", e); } } @@ -1332,7 +1330,6 @@ else if (MimeTypes.Type.MULTIPART_FORM_DATA.is(baseType) && } catch (IllegalStateException | IllegalArgumentException e) { - LOG.warn(e.toString()); throw new BadMessageException("Unable to parse form content", e); } } diff --git a/jetty-ee10/jetty-ee10-servlet/src/test/java/org/eclipse/jetty/ee10/servlet/ComplianceViolations2616Test.java b/jetty-ee10/jetty-ee10-servlet/src/test/java/org/eclipse/jetty/ee10/servlet/ComplianceViolations2616Test.java index a617a9f9aab1..631875151a06 100644 --- a/jetty-ee10/jetty-ee10-servlet/src/test/java/org/eclipse/jetty/ee10/servlet/ComplianceViolations2616Test.java +++ b/jetty-ee10/jetty-ee10-servlet/src/test/java/org/eclipse/jetty/ee10/servlet/ComplianceViolations2616Test.java @@ -110,7 +110,6 @@ public static void startServer() throws Exception config.addComplianceViolationListener(new ComplianceViolation.CapturingListener()); HttpConnectionFactory httpConnectionFactory = new HttpConnectionFactory(config); - httpConnectionFactory.setRecordHttpComplianceViolations(true); connector = new LocalConnector(server, null, null, null, -1, httpConnectionFactory); ServletContextHandler context = new ServletContextHandler(); diff --git a/jetty-ee10/jetty-ee10-servlets/src/test/java/org/eclipse/jetty/ee10/servlets/ThreadStarvationTest.java b/jetty-ee10/jetty-ee10-servlets/src/test/java/org/eclipse/jetty/ee10/servlets/ThreadStarvationTest.java index d7ce3f51eb3f..0b7dcffbd9a8 100644 --- a/jetty-ee10/jetty-ee10-servlets/src/test/java/org/eclipse/jetty/ee10/servlets/ThreadStarvationTest.java +++ b/jetty-ee10/jetty-ee10-servlets/src/test/java/org/eclipse/jetty/ee10/servlets/ThreadStarvationTest.java @@ -55,7 +55,7 @@ import org.eclipse.jetty.server.Response; import org.eclipse.jetty.server.Server; import org.eclipse.jetty.server.ServerConnector; -import org.eclipse.jetty.server.handler.DelayedHandler; +import org.eclipse.jetty.server.handler.EagerContentHandler; import org.eclipse.jetty.toolchain.test.MavenTestingUtils; import org.eclipse.jetty.util.BufferUtil; import org.eclipse.jetty.util.Callback; @@ -108,7 +108,7 @@ protected void doPut(HttpServletRequest req, HttpServletResponse resp) throws IO if (delayed) { - _server.insertHandler(new DelayedHandler()); + _server.insertHandler(new EagerContentHandler()); connector.getConnectionFactory(HttpConnectionFactory.class).getHttpConfiguration().setDelayDispatchUntilContent(true); } _server.start(); @@ -205,7 +205,7 @@ protected void doPost(HttpServletRequest req, HttpServletResponse resp) if (delayed) { - _server.insertHandler(new DelayedHandler()); + _server.insertHandler(new EagerContentHandler()); connector.getConnectionFactory(HttpConnectionFactory.class).getHttpConfiguration().setDelayDispatchUntilContent(true); } _server.start(); diff --git a/jetty-ee10/jetty-ee10-tests/jetty-ee10-test-integration/src/test/java/org/eclipse/jetty/ee10/test/HttpInputIntegrationTest.java b/jetty-ee10/jetty-ee10-tests/jetty-ee10-test-integration/src/test/java/org/eclipse/jetty/ee10/test/HttpInputIntegrationTest.java index a26a99ffac7d..4bb29bc15757 100644 --- a/jetty-ee10/jetty-ee10-tests/jetty-ee10-test-integration/src/test/java/org/eclipse/jetty/ee10/test/HttpInputIntegrationTest.java +++ b/jetty-ee10/jetty-ee10-tests/jetty-ee10-test-integration/src/test/java/org/eclipse/jetty/ee10/test/HttpInputIntegrationTest.java @@ -24,6 +24,7 @@ import java.util.List; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.Stream; import javax.net.ssl.SSLSocket; @@ -48,11 +49,15 @@ import org.eclipse.jetty.server.LocalConnector; import org.eclipse.jetty.server.LocalConnector.LocalEndPoint; import org.eclipse.jetty.server.NetworkConnector; +import org.eclipse.jetty.server.Request; +import org.eclipse.jetty.server.Response; import org.eclipse.jetty.server.SecureRequestCustomizer; import org.eclipse.jetty.server.Server; import org.eclipse.jetty.server.ServerConnector; import org.eclipse.jetty.server.SslConnectionFactory; +import org.eclipse.jetty.server.handler.EagerContentHandler; import org.eclipse.jetty.util.BufferUtil; +import org.eclipse.jetty.util.Callback; import org.eclipse.jetty.util.IO; import org.eclipse.jetty.util.component.LifeCycle; import org.eclipse.jetty.util.ssl.SslContextFactory; @@ -82,6 +87,7 @@ enum Mode private static HttpConfiguration __config; private static SslContextFactory.Server __sslContextFactory; private static ArrayByteBufferPool.Tracking __bufferPool; + private static final AtomicBoolean __eagerHandler = new AtomicBoolean(); @BeforeAll public static void beforeClass() throws Exception @@ -127,8 +133,20 @@ public static void beforeClass() throws Exception http2.setIdleTimeout(5000); __server.addConnector(http2); + EagerContentHandler eagerContentHandler = new EagerContentHandler() + { + @Override + public boolean onConditionsMet(Request request, Response response, Callback callback) throws Exception + { + if (__eagerHandler.get()) + return super.onConditionsMet(request, response, callback); + return getHandler().handle(request, response, callback); + } + }; + ServletContextHandler context = new ServletContextHandler("/ctx"); - __server.setHandler(context); + __server.setHandler(eagerContentHandler); + eagerContentHandler.setHandler(context); ServletHolder holder = new ServletHolder(new TestServlet()); holder.setAsyncSupported(true); context.addServlet(holder, "/*"); @@ -286,8 +304,9 @@ private static void runMode(Mode mode, ServletContextRequest request, Runnable t @MethodSource("scenarios") public void testOne(Scenario scenario) throws Exception { + __eagerHandler.set(scenario._eagerHandler); TestClient client = scenario._client.getDeclaredConstructor().newInstance(); - String response = client.send("/ctx/test?mode=" + scenario._mode, 50, scenario._delay, scenario._length, scenario._send); + String response = client.send("/ctx/test?mode=" + scenario._mode, 50, scenario._eager, scenario._length, scenario._send); int sum = 0; for (String s : scenario._send) @@ -309,6 +328,7 @@ public void testOne(Scenario scenario) throws Exception @MethodSource("scenarios") public void testStress(Scenario scenario) throws Exception { + __eagerHandler.set(scenario._eagerHandler); int sum = 0; for (String s : scenario._send) { @@ -332,7 +352,7 @@ public void testStress(Scenario scenario) throws Exception TestClient client = scenario._client.getDeclaredConstructor().newInstance(); for (int j = 0; j < loops; j++) { - String response = client.send("/ctx/test?mode=" + scenario._mode, 10, scenario._delay, scenario._length, scenario._send); + String response = client.send("/ctx/test?mode=" + scenario._mode, 10, scenario._eager, scenario._length, scenario._send); assertTrue(response.startsWith("HTTP"), response); assertTrue(response.contains(" " + scenario._status + " "), response); assertTrue(response.contains("read=" + scenario._read), response); @@ -664,18 +684,19 @@ public static class Scenario { private final Class _client; private final Mode _mode; - private final Boolean _delay; + private final boolean _eagerHandler; + private final Boolean _eager; private final int _status; private final int _read; private final int _length; private final List _send; - public Scenario(Class client, Mode mode, boolean dispatch, Boolean delay, int status, int read, int length, String... send) + public Scenario(Class client, Mode mode, boolean eagerHandler, Boolean eager, int status, int read, int length, String... send) { _client = client; _mode = mode; - __config.setDelayDispatchUntilContent(dispatch); - _delay = delay; + _eagerHandler = eagerHandler; + _eager = eager; _status = status; _read = read; _length = length; @@ -685,8 +706,8 @@ public Scenario(Class client, Mode mode, boolean dispatch, @Override public String toString() { - return String.format("c=%s, m=%s, delayDispatch=%b delayInFrame=%s content-length:%d expect=%d read=%d content:%s%n", - _client.getSimpleName(), _mode, __config.isDelayDispatchUntilContent(), _delay, _length, _status, _read, _send); + return String.format("c=%s, m=%s, delayInFrame=%s content-length:%d expect=%d read=%d content:%s%n", + _client.getSimpleName(), _mode, _eager, _length, _status, _read, _send); } } } diff --git a/jetty-ee10/jetty-ee10-webapp/src/test/java/org/eclipse/jetty/ee10/webapp/HugeResourceTest.java b/jetty-ee10/jetty-ee10-webapp/src/test/java/org/eclipse/jetty/ee10/webapp/HugeResourceTest.java index fda0e4dd1511..25af1bd0c5b3 100644 --- a/jetty-ee10/jetty-ee10-webapp/src/test/java/org/eclipse/jetty/ee10/webapp/HugeResourceTest.java +++ b/jetty-ee10/jetty-ee10-webapp/src/test/java/org/eclipse/jetty/ee10/webapp/HugeResourceTest.java @@ -58,7 +58,7 @@ import org.eclipse.jetty.server.HttpConnectionFactory; import org.eclipse.jetty.server.Server; import org.eclipse.jetty.server.ServerConnector; -import org.eclipse.jetty.server.handler.DelayedHandler; +import org.eclipse.jetty.server.handler.EagerContentHandler; import org.eclipse.jetty.toolchain.test.FS; import org.eclipse.jetty.toolchain.test.MavenTestingUtils; import org.eclipse.jetty.util.BufferUtil; @@ -223,11 +223,11 @@ public void startServer() throws Exception ServletHolder holder = context.addServlet(MultipartServlet.class, "/multipart"); holder.getRegistration().setMultipartConfig(multipartConfig); - DelayedHandler delayedHandler = new DelayedHandler(); - server.setHandler(delayedHandler); + EagerContentHandler eagerContentHandler = new EagerContentHandler(); + server.setHandler(eagerContentHandler); httpConfig.setDelayDispatchUntilContent(false); - delayedHandler.setHandler(context); + eagerContentHandler.setHandler(context); server.start(); } diff --git a/jetty-ee11/jetty-ee11-servlet/src/main/java/org/eclipse/jetty/ee11/servlet/EagerFormHandler.java b/jetty-ee11/jetty-ee11-servlet/src/main/java/org/eclipse/jetty/ee11/servlet/EagerFormHandler.java index 03b78c7a1bfd..d8b09b524159 100644 --- a/jetty-ee11/jetty-ee11-servlet/src/main/java/org/eclipse/jetty/ee11/servlet/EagerFormHandler.java +++ b/jetty-ee11/jetty-ee11-servlet/src/main/java/org/eclipse/jetty/ee11/servlet/EagerFormHandler.java @@ -28,7 +28,9 @@ * Handler to eagerly and asynchronously read and parse {@link MimeTypes.Type#FORM_ENCODED} and * {@link MimeTypes.Type#MULTIPART_FORM_DATA} content prior to invoking the {@link ServletHandler}, * which can then consume them with blocking APIs but without blocking. + * @deprecated use {@link org.eclipse.jetty.server.handler.EagerContentHandler} */ +@Deprecated(forRemoval = true, since = "12.1.0") public class EagerFormHandler extends Handler.Wrapper { public EagerFormHandler() diff --git a/jetty-ee11/jetty-ee11-servlet/src/main/java/org/eclipse/jetty/ee11/servlet/ServletApiRequest.java b/jetty-ee11/jetty-ee11-servlet/src/main/java/org/eclipse/jetty/ee11/servlet/ServletApiRequest.java index dd3c2324f110..9b14fb57dd6e 100644 --- a/jetty-ee11/jetty-ee11-servlet/src/main/java/org/eclipse/jetty/ee11/servlet/ServletApiRequest.java +++ b/jetty-ee11/jetty-ee11-servlet/src/main/java/org/eclipse/jetty/ee11/servlet/ServletApiRequest.java @@ -1277,7 +1277,7 @@ private void extractContentParameters() throws BadMessageException try { int contentLength = getContentLength(); - if (contentLength != 0 && _inputState == ServletContextRequest.INPUT_NONE) + if (contentLength != 0) { String baseType = HttpField.getValueParameters(getContentType(), null); if (MimeTypes.Type.FORM_ENCODED.is(baseType) && @@ -1292,7 +1292,6 @@ private void extractContentParameters() throws BadMessageException } catch (IllegalStateException | IllegalArgumentException | CompletionException e) { - LOG.warn(e.toString()); throw new BadMessageException("Unable to parse form content", e); } } @@ -1330,7 +1329,6 @@ else if (MimeTypes.Type.MULTIPART_FORM_DATA.is(baseType) && } catch (IllegalStateException | IllegalArgumentException | CompletionException e) { - LOG.warn(e.toString()); throw new BadMessageException("Unable to parse form content", e); } } @@ -1341,7 +1339,6 @@ else if (MimeTypes.Type.MULTIPART_FORM_DATA.is(baseType) && } catch (IllegalStateException | IllegalArgumentException e) { - LOG.warn(e.toString()); throw new BadMessageException("Unable to parse form content", e); } } diff --git a/jetty-ee11/jetty-ee11-servlet/src/test/java/org/eclipse/jetty/ee11/servlet/ComplianceViolations2616Test.java b/jetty-ee11/jetty-ee11-servlet/src/test/java/org/eclipse/jetty/ee11/servlet/ComplianceViolations2616Test.java index 5da1f083d4b1..db43ab5f7da2 100644 --- a/jetty-ee11/jetty-ee11-servlet/src/test/java/org/eclipse/jetty/ee11/servlet/ComplianceViolations2616Test.java +++ b/jetty-ee11/jetty-ee11-servlet/src/test/java/org/eclipse/jetty/ee11/servlet/ComplianceViolations2616Test.java @@ -110,7 +110,6 @@ public static void startServer() throws Exception config.addComplianceViolationListener(new ComplianceViolation.CapturingListener()); HttpConnectionFactory httpConnectionFactory = new HttpConnectionFactory(config); - httpConnectionFactory.setRecordHttpComplianceViolations(true); connector = new LocalConnector(server, null, null, null, -1, httpConnectionFactory); ServletContextHandler context = new ServletContextHandler(); diff --git a/jetty-ee11/jetty-ee11-servlets/src/test/java/org/eclipse/jetty/ee11/servlets/ThreadStarvationTest.java b/jetty-ee11/jetty-ee11-servlets/src/test/java/org/eclipse/jetty/ee11/servlets/ThreadStarvationTest.java index 17aa893ada53..5cf98a562546 100644 --- a/jetty-ee11/jetty-ee11-servlets/src/test/java/org/eclipse/jetty/ee11/servlets/ThreadStarvationTest.java +++ b/jetty-ee11/jetty-ee11-servlets/src/test/java/org/eclipse/jetty/ee11/servlets/ThreadStarvationTest.java @@ -55,7 +55,7 @@ import org.eclipse.jetty.server.Response; import org.eclipse.jetty.server.Server; import org.eclipse.jetty.server.ServerConnector; -import org.eclipse.jetty.server.handler.DelayedHandler; +import org.eclipse.jetty.server.handler.EagerContentHandler; import org.eclipse.jetty.toolchain.test.MavenTestingUtils; import org.eclipse.jetty.util.BufferUtil; import org.eclipse.jetty.util.Callback; @@ -108,7 +108,7 @@ protected void doPut(HttpServletRequest req, HttpServletResponse resp) throws IO if (delayed) { - _server.insertHandler(new DelayedHandler()); + _server.insertHandler(new EagerContentHandler()); connector.getConnectionFactory(HttpConnectionFactory.class).getHttpConfiguration().setDelayDispatchUntilContent(true); } _server.start(); @@ -205,7 +205,7 @@ protected void doPost(HttpServletRequest req, HttpServletResponse resp) if (delayed) { - _server.insertHandler(new DelayedHandler()); + _server.insertHandler(new EagerContentHandler()); connector.getConnectionFactory(HttpConnectionFactory.class).getHttpConfiguration().setDelayDispatchUntilContent(true); } _server.start(); diff --git a/jetty-ee11/jetty-ee11-webapp/src/test/java/org/eclipse/jetty/ee11/webapp/HugeResourceTest.java b/jetty-ee11/jetty-ee11-webapp/src/test/java/org/eclipse/jetty/ee11/webapp/HugeResourceTest.java index 97d3a92b12aa..8393885b5fee 100644 --- a/jetty-ee11/jetty-ee11-webapp/src/test/java/org/eclipse/jetty/ee11/webapp/HugeResourceTest.java +++ b/jetty-ee11/jetty-ee11-webapp/src/test/java/org/eclipse/jetty/ee11/webapp/HugeResourceTest.java @@ -58,7 +58,7 @@ import org.eclipse.jetty.server.HttpConnectionFactory; import org.eclipse.jetty.server.Server; import org.eclipse.jetty.server.ServerConnector; -import org.eclipse.jetty.server.handler.DelayedHandler; +import org.eclipse.jetty.server.handler.EagerContentHandler; import org.eclipse.jetty.toolchain.test.FS; import org.eclipse.jetty.toolchain.test.MavenTestingUtils; import org.eclipse.jetty.util.BufferUtil; @@ -223,11 +223,11 @@ public void startServer() throws Exception ServletHolder holder = context.addServlet(MultipartServlet.class, "/multipart"); holder.getRegistration().setMultipartConfig(multipartConfig); - DelayedHandler delayedHandler = new DelayedHandler(); - server.setHandler(delayedHandler); + EagerContentHandler eagerContentHandler = new EagerContentHandler(); + server.setHandler(eagerContentHandler); httpConfig.setDelayDispatchUntilContent(false); - delayedHandler.setHandler(context); + eagerContentHandler.setHandler(context); server.start(); } diff --git a/jetty-ee9/jetty-ee9-nested/src/test/java/org/eclipse/jetty/ee9/nested/RequestTest.java b/jetty-ee9/jetty-ee9-nested/src/test/java/org/eclipse/jetty/ee9/nested/RequestTest.java index f274322b939d..5dbe03ee85c1 100644 --- a/jetty-ee9/jetty-ee9-nested/src/test/java/org/eclipse/jetty/ee9/nested/RequestTest.java +++ b/jetty-ee9/jetty-ee9-nested/src/test/java/org/eclipse/jetty/ee9/nested/RequestTest.java @@ -130,7 +130,6 @@ public void init() throws Exception _server = new Server(); _context = new ContextHandler(_server); HttpConnectionFactory http = new HttpConnectionFactory(); - http.setRecordHttpComplianceViolations(true); http.setInputBufferSize(1024); http.getHttpConfiguration().setRequestHeaderSize(512); http.getHttpConfiguration().setResponseHeaderSize(512); diff --git a/jetty-ee9/jetty-ee9-servlet/src/test/java/org/eclipse/jetty/ee9/servlet/ComplianceViolations2616Test.java b/jetty-ee9/jetty-ee9-servlet/src/test/java/org/eclipse/jetty/ee9/servlet/ComplianceViolations2616Test.java index e8f49f5436d9..a525885fd8e0 100644 --- a/jetty-ee9/jetty-ee9-servlet/src/test/java/org/eclipse/jetty/ee9/servlet/ComplianceViolations2616Test.java +++ b/jetty-ee9/jetty-ee9-servlet/src/test/java/org/eclipse/jetty/ee9/servlet/ComplianceViolations2616Test.java @@ -108,7 +108,6 @@ public static void startServer() throws Exception config.addComplianceViolationListener(new ComplianceViolation.CapturingListener()); HttpConnectionFactory httpConnectionFactory = new HttpConnectionFactory(config); - httpConnectionFactory.setRecordHttpComplianceViolations(true); connector = new LocalConnector(server, null, null, null, -1, httpConnectionFactory); ServletContextHandler context = new ServletContextHandler(); diff --git a/jetty-ee9/jetty-ee9-webapp/src/test/java/org/eclipse/jetty/ee9/webapp/TempDirTest.java b/jetty-ee9/jetty-ee9-webapp/src/test/java/org/eclipse/jetty/ee9/webapp/TempDirTest.java index 0b213efb739c..d32ba0494525 100644 --- a/jetty-ee9/jetty-ee9-webapp/src/test/java/org/eclipse/jetty/ee9/webapp/TempDirTest.java +++ b/jetty-ee9/jetty-ee9-webapp/src/test/java/org/eclipse/jetty/ee9/webapp/TempDirTest.java @@ -36,7 +36,6 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertTrue; @ExtendWith(WorkDirExtension.class) public class TempDirTest diff --git a/jetty-ee9/jetty-ee9-webapp/src/test/java/org/eclipse/jetty/ee9/webapp/WebInfConfigurationTest.java b/jetty-ee9/jetty-ee9-webapp/src/test/java/org/eclipse/jetty/ee9/webapp/WebInfConfigurationTest.java index 81bd2b8ac2ca..58e794c957ab 100644 --- a/jetty-ee9/jetty-ee9-webapp/src/test/java/org/eclipse/jetty/ee9/webapp/WebInfConfigurationTest.java +++ b/jetty-ee9/jetty-ee9-webapp/src/test/java/org/eclipse/jetty/ee9/webapp/WebInfConfigurationTest.java @@ -47,7 +47,6 @@ import org.junit.jupiter.params.provider.MethodSource; import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.startsWith; diff --git a/tests/test-distribution/test-distribution-common/src/test/java/org/eclipse/jetty/tests/distribution/DistributionTests.java b/tests/test-distribution/test-distribution-common/src/test/java/org/eclipse/jetty/tests/distribution/DistributionTests.java index 56b24d8ae4ed..2abbf4966ac5 100644 --- a/tests/test-distribution/test-distribution-common/src/test/java/org/eclipse/jetty/tests/distribution/DistributionTests.java +++ b/tests/test-distribution/test-distribution-common/src/test/java/org/eclipse/jetty/tests/distribution/DistributionTests.java @@ -21,6 +21,7 @@ import java.net.UnknownHostException; import java.net.http.WebSocket; import java.nio.ByteBuffer; +import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; @@ -31,24 +32,33 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.UUID; import java.util.concurrent.CompletionStage; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; +import java.util.stream.IntStream; import java.util.stream.Stream; +import org.eclipse.jetty.client.BytesRequestContent; import org.eclipse.jetty.client.ContentResponse; +import org.eclipse.jetty.client.FormRequestContent; import org.eclipse.jetty.client.HttpClient; +import org.eclipse.jetty.client.MultiPartRequestContent; import org.eclipse.jetty.client.transport.HttpClientConnectionFactory; import org.eclipse.jetty.client.transport.HttpClientTransportDynamic; import org.eclipse.jetty.client.transport.HttpClientTransportOverHTTP; +import org.eclipse.jetty.http.HttpFields; import org.eclipse.jetty.http.HttpHeader; import org.eclipse.jetty.http.HttpScheme; import org.eclipse.jetty.http.HttpStatus; +import org.eclipse.jetty.http.HttpVersion; import org.eclipse.jetty.http.MultiPart; import org.eclipse.jetty.http.MultiPartByteRanges; import org.eclipse.jetty.http2.client.HTTP2Client; +import org.eclipse.jetty.http2.client.transport.ClientConnectionFactoryOverHTTP2; import org.eclipse.jetty.http2.client.transport.HttpClientTransportOverHTTP2; import org.eclipse.jetty.http3.client.HTTP3Client; import org.eclipse.jetty.http3.client.transport.HttpClientTransportOverHTTP3; @@ -62,7 +72,10 @@ import org.eclipse.jetty.toolchain.test.FS; import org.eclipse.jetty.toolchain.test.PathMatchers; import org.eclipse.jetty.util.BlockingArrayQueue; +import org.eclipse.jetty.util.Fields; +import org.eclipse.jetty.util.MultiMap; import org.eclipse.jetty.util.StringUtil; +import org.eclipse.jetty.util.UrlEncoded; import org.eclipse.jetty.util.ssl.SslContextFactory; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; @@ -71,6 +84,7 @@ import org.junit.jupiter.api.condition.JRE; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.EnumSource; import org.junit.jupiter.params.provider.ValueSource; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -1962,4 +1976,186 @@ public void testHTTP2ClientInCoreWebAppProvidedByServer() throws Exception } } } + + @ParameterizedTest + @EnumSource(value = HttpVersion.class, names = {"HTTP_1_1", "HTTP_2"}) + public void testEagerContentHandler(HttpVersion httpVersion) throws Exception + { + String jettyVersion = System.getProperty("jettyVersion"); + JettyHomeTester distribution = JettyHomeTester.Builder.newInstance() + .jettyVersion(jettyVersion) + .build(); + + try (JettyHomeTester.Run run1 = distribution.start("--add-modules=resources,test-keystore,http,http2c,ee11-deploy,ee11-annotations,eager-content")) + { + assertTrue(run1.awaitFor(START_TIMEOUT, TimeUnit.SECONDS)); + assertEquals(0, run1.getExitValue()); + + Path war = distribution.resolveArtifact("org.eclipse.jetty.demos:jetty-servlet5-demo-simple-webapp:war:" + jettyVersion); + String contextPath = "ctx"; + distribution.installWar(war, contextPath); + + int port = Tester.freePort(); + int maxRetainedBytes = 128; + String[] properties = { + "jetty.http.selectors=1", + "jetty.http.port=" + port, + "jetty.eager.content.framingOverhead=16", + "jetty.eager.content.maxRetainedBytes=" + maxRetainedBytes, + "jetty.eager.content.rejectWhenExceeded=false" + }; + try (JettyHomeTester.Run run2 = distribution.start(properties)) + { + assertTrue(run2.awaitConsoleLogsFor("Started oejs.Server@", START_TIMEOUT, TimeUnit.SECONDS)); + + startHttpClient(() -> + { + ClientConnector connector = new ClientConnector(); + HTTP2Client h2Client = new HTTP2Client(connector); + return new HttpClient(new HttpClientTransportDynamic(connector, HttpClientConnectionFactory.HTTP11, new ClientConnectionFactoryOverHTTP2.HTTP2(h2Client))); + }); + + IntStream.of(maxRetainedBytes / 2, maxRetainedBytes * 10).forEach(contentLength -> + { + try + { + ContentResponse response = client.newRequest("http://localhost:" + port + "/" + contextPath + "/echo/content") + .method("POST") + .version(httpVersion) + .body(new BytesRequestContent(new byte[contentLength])) + .send(); + + assertEquals(HttpStatus.OK_200, response.getStatus()); + assertEquals(contentLength, response.getContent().length); + } + catch (Exception x) + { + throw new RuntimeException(x); + } + }); + } + } + } + + @ParameterizedTest + @EnumSource(value = HttpVersion.class, names = {"HTTP_1_1", "HTTP_2"}) + public void testEagerFormContentHandler(HttpVersion httpVersion) throws Exception + { + String jettyVersion = System.getProperty("jettyVersion"); + JettyHomeTester distribution = JettyHomeTester.Builder.newInstance() + .jettyVersion(jettyVersion) + .build(); + + try (JettyHomeTester.Run run1 = distribution.start("--add-modules=resources,test-keystore,http,http2c,ee11-deploy,ee11-annotations,eager-content")) + { + assertTrue(run1.awaitFor(START_TIMEOUT, TimeUnit.SECONDS)); + assertEquals(0, run1.getExitValue()); + + Path war = distribution.resolveArtifact("org.eclipse.jetty.demos:jetty-servlet5-demo-simple-webapp:war:" + jettyVersion); + String contextPath = "ctx"; + distribution.installWar(war, contextPath); + + int port = Tester.freePort(); + String[] properties = { + "jetty.http.selectors=1", + "jetty.http.port=" + port, + "jetty.eager.form.maxFields=16", + "jetty.eager.form.maxLength=128" + }; + try (JettyHomeTester.Run run2 = distribution.start(properties)) + { + assertTrue(run2.awaitConsoleLogsFor("Started oejs.Server@", START_TIMEOUT, TimeUnit.SECONDS)); + + startHttpClient(() -> + { + ClientConnector connector = new ClientConnector(); + HTTP2Client h2Client = new HTTP2Client(connector); + return new HttpClient(new HttpClientTransportDynamic(connector, HttpClientConnectionFactory.HTTP11, new ClientConnectionFactoryOverHTTP2.HTTP2(h2Client))); + }); + + Map> inMap = new LinkedHashMap<>(); + inMap.put("greet", List.of("Hello World")); + inMap.put("currency", List.of("€")); + Charset charset = StandardCharsets.UTF_8; + ContentResponse response = client.newRequest("http://localhost:" + port + "/" + contextPath + "/echo/form") + .method("POST") + .version(httpVersion) + .body(new FormRequestContent(new Fields(new MultiMap<>(inMap)), charset)) + .send(); + + assertEquals(HttpStatus.OK_200, response.getStatus()); + LinkedHashMap> outMap = new LinkedHashMap<>(); + UrlEncoded.decodeTo(response.getContentAsString(), (name, value) -> outMap.computeIfAbsent(name, k -> new ArrayList<>()).add(value), charset); + assertEquals(inMap, outMap); + } + } + } + + @ParameterizedTest + @EnumSource(value = HttpVersion.class, names = {"HTTP_1_1", "HTTP_2"}) + public void testEagerMultiPartContentHandler(HttpVersion httpVersion) throws Exception + { + String jettyVersion = System.getProperty("jettyVersion"); + JettyHomeTester distribution = JettyHomeTester.Builder.newInstance() + .jettyVersion(jettyVersion) + .build(); + + try (JettyHomeTester.Run run1 = distribution.start("--add-modules=resources,test-keystore,http,http2c,ee11-deploy,ee11-annotations,eager-content")) + { + assertTrue(run1.awaitFor(START_TIMEOUT, TimeUnit.SECONDS)); + assertEquals(0, run1.getExitValue()); + + Path jettyLogging = distribution.getJettyBase().resolve("resources/jetty-logging.properties"); + String loggingConfig = """ + org.eclipse.jetty.LEVEL=DEBUG + """; + Files.writeString(jettyLogging, loggingConfig, StandardOpenOption.TRUNCATE_EXISTING); + long fileLength = Files.size(jettyLogging); + + Path war = distribution.resolveArtifact("org.eclipse.jetty.demos:jetty-servlet5-demo-simple-webapp:war:" + jettyVersion); + String contextPath = "ctx"; + distribution.installWar(war, contextPath); + + Path work = distribution.getJettyBase().resolve("work"); + + int port = Tester.freePort(); + String[] properties = { + "jetty.http.selectors=1", + "jetty.http.port=" + port, + "jetty.eager.multipart.location=" + work.toAbsolutePath(), + "jetty.eager.multipart.maxParts=3", + "jetty.eager.multipart.maxSize=1024", + "jetty.eager.multipart.maxMemoryPartSize=0", + "jetty.eager.multipart.maxHeadersSize=1024", + "jetty.eager.multipart.useFilesForPartsWithoutFileName=true" + }; + try (JettyHomeTester.Run run2 = distribution.start(properties)) + { + assertTrue(run2.awaitConsoleLogsFor("Started oejs.Server@", START_TIMEOUT, TimeUnit.SECONDS)); + + startHttpClient(() -> + { + ClientConnector connector = new ClientConnector(); + HTTP2Client h2Client = new HTTP2Client(connector); + return new HttpClient(new HttpClientTransportDynamic(connector, HttpClientConnectionFactory.HTTP11, new ClientConnectionFactoryOverHTTP2.HTTP2(h2Client))); + }); + + MultiPartRequestContent content = new MultiPartRequestContent(); + content.addPart(new MultiPart.ByteBufferPart("part1", null, HttpFields.EMPTY, StandardCharsets.UTF_8.encode("13-bytes-long"))); + content.addPart(new MultiPart.PathPart("part2", null, HttpFields.EMPTY, distribution.getJettyBase().resolve("resources/jetty-logging.properties"))); + content.close(); + ContentResponse response = client.newRequest("http://localhost:" + port + "/" + contextPath + "/echo/multipart") + .method("POST") + .version(httpVersion) + .body(content) + .send(); + + assertEquals(HttpStatus.OK_200, response.getStatus()); + String[] lines = StringUtil.csvSplit(response.getContentAsString()); + assertEquals(2, lines.length); + assertEquals(lines[0], "name=part1&length=13"); + assertEquals(lines[1], "name=part2&length=" + fileLength); + } + } + } }