From e0b54e244e7ac9f48f720bcbdcc869e2400fe35c Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Tue, 24 Feb 2026 21:38:49 +0100 Subject: [PATCH] Ignore flushes on ServletServerHttpResponse output stream Prior to this commit, flush calls on the output stream returned by `ServletServerHttpResponse#getBody` would be delegated to the Servlet response output stream. This can cause performance issues when `HttpMessageConverter` and other web components write and flush multiple times to the response body. Here, the Servlet container is in a better position to flush to the network at the optimal time and buffer the response body until then. This is particularly true for `HttpMessageConverters` when they flush many times the output stream, sometimes due to the underlying codec library. Instead of revisiting the entire message converter contract, we are here ignoring flush calls to that output stream. This change does not affect the client side, nor the `ServletServerHttpResponse#flush` calls. This commit also introduces a new Spring property `"spring.http.response.flush.enabled"` that reverts this behavior change if necessary. Closes gh-36385 --- .../modules/ROOT/pages/appendix.adoc | 5 +++ .../server/ServletServerHttpResponse.java | 26 ++++++++++++- .../ServletServerHttpResponseTests.java | 37 +++++++++++++++++++ .../HttpHeadersReturnValueHandler.java | 2 +- ...reamingResponseBodyReturnValueHandler.java | 13 +++---- 5 files changed, 74 insertions(+), 9 deletions(-) diff --git a/framework-docs/modules/ROOT/pages/appendix.adoc b/framework-docs/modules/ROOT/pages/appendix.adoc index 3b367aef678..dcfaacb27b3 100644 --- a/framework-docs/modules/ROOT/pages/appendix.adoc +++ b/framework-docs/modules/ROOT/pages/appendix.adoc @@ -81,6 +81,11 @@ resolvable otherwise. See {spring-framework-api}++/core/env/AbstractEnvironment.html#IGNORE_GETENV_PROPERTY_NAME++[`AbstractEnvironment`] for details. +| `spring.http.response.flush.enabled` +| Configures the Spring MVC `ServletServerHttpResponse` to allow flushing on the `OutputStream` +returned by `ServletServerHttpResponse#getBody()`. By default, such flush calls are ignored and +only `ServletServerHttpResponse#flush()` will actually flush the response to the network. + | `spring.jdbc.getParameterType.ignore` | Instructs Spring to ignore `java.sql.ParameterMetaData.getParameterType` completely. See the note in xref:data-access/jdbc/advanced.adoc#jdbc-batch-list[Batch Operations with a List of Objects]. diff --git a/spring-web/src/main/java/org/springframework/http/server/ServletServerHttpResponse.java b/spring-web/src/main/java/org/springframework/http/server/ServletServerHttpResponse.java index e82ce036ede..4d8c1129bd5 100644 --- a/spring-web/src/main/java/org/springframework/http/server/ServletServerHttpResponse.java +++ b/spring-web/src/main/java/org/springframework/http/server/ServletServerHttpResponse.java @@ -23,20 +23,35 @@ import java.nio.charset.Charset; import jakarta.servlet.http.HttpServletResponse; import org.jspecify.annotations.Nullable; +import org.springframework.core.SpringProperties; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatusCode; import org.springframework.http.MediaType; import org.springframework.util.Assert; +import org.springframework.util.StreamUtils; /** * {@link ServerHttpResponse} implementation that is based on a {@link HttpServletResponse}. * * @author Arjen Poutsma * @author Rossen Stoyanchev + * @author Brian Clozel * @since 3.0 */ public class ServletServerHttpResponse implements ServerHttpResponse { + /** + * System property that indicates whether {@code response.getBody().flush()} + * should effectively flush to the network. This is by default disabled, + * and developers must set the {@code "spring.http.response.flush.enabled"} + * {@link org.springframework.core.SpringProperties Spring property} to + * turn it on. + *

Applications should instead {@link #flush()} on the response directly. + */ + public static final String BODY_FLUSH_ENABLED = "spring.http.response.flush.enabled"; + + private final boolean flushEnabled = SpringProperties.getFlag(BODY_FLUSH_ENABLED); + private final HttpServletResponse servletResponse; private final HttpHeaders headers; @@ -86,11 +101,20 @@ public class ServletServerHttpResponse implements ServerHttpResponse { } } + /** + * Return the body of the message as an output stream. + *

By default, flushing the output stream has no effect + * (see {@link #BODY_FLUSH_ENABLED}) and should be performed + * using {@link #flush()} instead. + * @return the output stream body (never {@code null}) + * @throws IOException in case of I/O errors + */ @Override public OutputStream getBody() throws IOException { this.bodyUsed = true; writeHeaders(); - return this.servletResponse.getOutputStream(); + return (this.flushEnabled) ? this.servletResponse.getOutputStream() : + StreamUtils.nonFlushing(this.servletResponse.getOutputStream()); } @Override diff --git a/spring-web/src/test/java/org/springframework/http/server/ServletServerHttpResponseTests.java b/spring-web/src/test/java/org/springframework/http/server/ServletServerHttpResponseTests.java index d10e0d76655..89da9e61b4e 100644 --- a/spring-web/src/test/java/org/springframework/http/server/ServletServerHttpResponseTests.java +++ b/spring-web/src/test/java/org/springframework/http/server/ServletServerHttpResponseTests.java @@ -19,9 +19,12 @@ package org.springframework.http.server; import java.nio.charset.StandardCharsets; import java.util.List; +import jakarta.servlet.ServletOutputStream; +import jakarta.servlet.http.HttpServletResponse; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.springframework.core.SpringProperties; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; @@ -29,8 +32,13 @@ import org.springframework.util.FileCopyUtils; import org.springframework.web.testfixture.servlet.MockHttpServletResponse; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; /** + * Tests for {@link ServletServerHttpResponse}. * @author Arjen Poutsma * @author Rossen Stoyanchev * @author Juergen Hoeller @@ -120,4 +128,33 @@ class ServletServerHttpResponseTests { assertThat(mockResponse.getContentAsByteArray()).as("Invalid content written").isEqualTo(content); } + @Test + void skipFlushCallsOnOutputStream() throws Exception { + ServletOutputStream mockStream = mock(); + HttpServletResponse mockResponse = mock(); + when(mockResponse.getOutputStream()).thenReturn(mockStream); + + this.response = new ServletServerHttpResponse(mockResponse); + byte[] content = "Hello World".getBytes(StandardCharsets.UTF_8); + FileCopyUtils.copy(content, response.getBody()); + response.getBody().flush(); + verify(mockStream, never()).flush(); + } + + @Test + void appliesFlushCallsOnOutputStream() throws Exception { + SpringProperties.setProperty(ServletServerHttpResponse.BODY_FLUSH_ENABLED, Boolean.TRUE.toString()); + ServletOutputStream mockStream = mock(); + HttpServletResponse mockResponse = mock(); + when(mockResponse.getOutputStream()).thenReturn(mockStream); + + this.response = new ServletServerHttpResponse(mockResponse); + byte[] content = "Hello World".getBytes(StandardCharsets.UTF_8); + FileCopyUtils.copy(content, response.getBody()); + response.getBody().flush(); + verify(mockStream).flush(); + + SpringProperties.setProperty(ServletServerHttpResponse.BODY_FLUSH_ENABLED, null); + } + } diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/HttpHeadersReturnValueHandler.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/HttpHeadersReturnValueHandler.java index 3ba086d1c96..55880cd7aee 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/HttpHeadersReturnValueHandler.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/HttpHeadersReturnValueHandler.java @@ -55,7 +55,7 @@ public class HttpHeadersReturnValueHandler implements HandlerMethodReturnValueHa Assert.state(servletResponse != null, "No HttpServletResponse"); ServletServerHttpResponse outputMessage = new ServletServerHttpResponse(servletResponse); outputMessage.getHeaders().putAll(headers); - outputMessage.getBody(); // flush headers + outputMessage.flush(); // flush headers } } diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/StreamingResponseBodyReturnValueHandler.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/StreamingResponseBodyReturnValueHandler.java index efd4af42b9b..55fe4c47fd2 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/StreamingResponseBodyReturnValueHandler.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/StreamingResponseBodyReturnValueHandler.java @@ -16,7 +16,6 @@ package org.springframework.web.servlet.mvc.method.annotation; -import java.io.OutputStream; import java.util.concurrent.Callable; import jakarta.servlet.ServletRequest; @@ -89,26 +88,26 @@ public class StreamingResponseBodyReturnValueHandler implements HandlerMethodRet Assert.isInstanceOf(StreamingResponseBody.class, returnValue, "StreamingResponseBody expected"); StreamingResponseBody streamingBody = (StreamingResponseBody) returnValue; - Callable callable = new StreamingResponseBodyTask(outputMessage.getBody(), streamingBody); + Callable callable = new StreamingResponseBodyTask(outputMessage, streamingBody); WebAsyncUtils.getAsyncManager(webRequest).startCallableProcessing(callable, mavContainer); } private static class StreamingResponseBodyTask implements Callable { - private final OutputStream outputStream; + private final ServerHttpResponse outputMessage; private final StreamingResponseBody streamingBody; - public StreamingResponseBodyTask(OutputStream outputStream, StreamingResponseBody streamingBody) { - this.outputStream = outputStream; + public StreamingResponseBodyTask(ServerHttpResponse outputMessage, StreamingResponseBody streamingBody) { + this.outputMessage = outputMessage; this.streamingBody = streamingBody; } @Override public Void call() throws Exception { - this.streamingBody.writeTo(this.outputStream); - this.outputStream.flush(); + this.streamingBody.writeTo(this.outputMessage.getBody()); + this.outputMessage.flush(); return null; } }