Skip to content

Commit c6b6ccd

Browse files
committed
Close ResponseBodyEmitter in case of write errors
Prior to this commit, the `ReactiveTypeHandler` would handle `Flux`-like return types from controller methods and adapt them to SSE streams using the `SseEmitter`/`ResponseBodyEmitter` APIs. In case an `IOException` is thrown while writing to the HTTP response stream, the `ReactiveTypeHandler` would rely on the Servlet container to call `AsyncListener#onError` - this would be the signal for Spring MVC to complete the async exchange. To prevent racing issues between this signal and the actual handling of the exception, changes like gh-20173 were applied. Since then, robust checks were added with gh-32340 in `StandardServletAsyncWebRequest.LifecycleHttpServletResponse`. With Jetty 12, `AsyncListener#onError` would not be called as the error would happen while writing in blocking mode to the response (so, not using the Servlet WriteListener contract). But still, such `IOException` would still result in the closing of the HTTP connection. As of Jetty 12.0.4, this is no longer the case and the party managing the async lifecycle is in charge of completing the exchange, as it should. This means that the current behavior leaks HTTP connections for these cases and causes memory issues. This commit ensures that such exceptions happening during response writes are caught and result in the completion of the `SSEEmitter` and the closing of the exchange. Even if other Servlet containers still propagate the error `AsyncListener#onError`, competing signals are still managed with gh-32340. Closes gh-32629
1 parent 09b8fea commit c6b6ccd

File tree

4 files changed

+53
-39
lines changed

4 files changed

+53
-39
lines changed

spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ReactiveTypeHandler.java

+1
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,7 @@ public void run() {
337337
logger.trace("Send for " + this.emitter + " failed: " + ex);
338338
}
339339
terminate();
340+
this.emitter.completeWithError(ex);
340341
return;
341342
}
342343
}

spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ResponseBodyEmitter.java

-20
Original file line numberDiff line numberDiff line change
@@ -79,16 +79,6 @@ public class ResponseBodyEmitter {
7979
@Nullable
8080
private Throwable failure;
8181

82-
/**
83-
* After an I/O error, we don't call {@link #completeWithError} directly but
84-
* wait for the Servlet container to call us via {@code AsyncListener#onError}
85-
* on a container thread at which point we call completeWithError.
86-
* This flag is used to ignore further calls to complete or completeWithError
87-
* that may come for example from an application try-catch block on the
88-
* thread of the I/O error.
89-
*/
90-
private boolean ioErrorOnSend;
91-
9282
private final DefaultCallback timeoutCallback = new DefaultCallback();
9383

9484
private final ErrorCallback errorCallback = new ErrorCallback();
@@ -198,7 +188,6 @@ public synchronized void send(Object object, @Nullable MediaType mediaType) thro
198188
this.handler.send(object, mediaType);
199189
}
200190
catch (IOException ex) {
201-
this.ioErrorOnSend = true;
202191
throw ex;
203192
}
204193
catch (Throwable ex) {
@@ -234,7 +223,6 @@ private void sendInternal(Set<DataWithMediaType> items) throws IOException {
234223
this.handler.send(items);
235224
}
236225
catch (IOException ex) {
237-
this.ioErrorOnSend = true;
238226
throw ex;
239227
}
240228
catch (Throwable ex) {
@@ -255,10 +243,6 @@ private void sendInternal(Set<DataWithMediaType> items) throws IOException {
255243
* related events such as an error while {@link #send(Object) sending}.
256244
*/
257245
public synchronized void complete() {
258-
// Ignore complete after IO failure on send
259-
if (this.ioErrorOnSend) {
260-
return;
261-
}
262246
this.complete = true;
263247
if (this.handler != null) {
264248
this.handler.complete();
@@ -277,10 +261,6 @@ public synchronized void complete() {
277261
* {@link #send(Object) sending}.
278262
*/
279263
public synchronized void completeWithError(Throwable ex) {
280-
// Ignore complete after IO failure on send
281-
if (this.ioErrorOnSend) {
282-
return;
283-
}
284264
this.complete = true;
285265
this.failure = ex;
286266
if (this.handler != null) {

spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/ReactiveTypeHandlerTests.java

+52-2
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
package org.springframework.web.servlet.mvc.method.annotation;
1818

19+
import java.io.IOException;
1920
import java.util.ArrayList;
2021
import java.util.Arrays;
2122
import java.util.Collections;
@@ -372,6 +373,24 @@ void writeText() throws Exception {
372373
assertThat(emitterHandler.getValuesAsText()).isEqualTo("The quick brown fox jumps over the lazy dog");
373374
}
374375

376+
@Test
377+
void failOnWriteShouldCompleteEmitter() throws Exception {
378+
379+
Sinks.Many<String> sink = Sinks.many().unicast().onBackpressureBuffer();
380+
ResponseBodyEmitter emitter = handleValue(sink.asFlux(), Flux.class, forClass(String.class));
381+
382+
ErroringEmitterHandler emitterHandler = new ErroringEmitterHandler();
383+
emitter.initialize(emitterHandler);
384+
385+
sink.tryEmitNext("The quick");
386+
sink.tryEmitNext(" brown fox jumps over ");
387+
sink.tryEmitNext("the lazy dog");
388+
sink.tryEmitComplete();
389+
390+
assertThat(emitterHandler.getHandlingStatus()).isEqualTo(HandlingStatus.ERROR);
391+
assertThat(emitterHandler.getFailure()).isInstanceOf(IOException.class);
392+
}
393+
375394
@Test
376395
void writeFluxOfString() throws Exception {
377396

@@ -451,6 +470,10 @@ private static class EmitterHandler implements ResponseBodyEmitter.Handler {
451470

452471
private final List<Object> values = new ArrayList<>();
453472

473+
private HandlingStatus handlingStatus;
474+
475+
private Throwable failure;
476+
454477

455478
public List<?> getValues() {
456479
return this.values;
@@ -460,22 +483,33 @@ public String getValuesAsText() {
460483
return this.values.stream().map(Object::toString).collect(Collectors.joining());
461484
}
462485

486+
public HandlingStatus getHandlingStatus() {
487+
return this.handlingStatus;
488+
}
489+
490+
public Throwable getFailure() {
491+
return this.failure;
492+
}
493+
463494
@Override
464-
public void send(Object data, MediaType mediaType) {
495+
public void send(Object data, MediaType mediaType) throws IOException {
465496
this.values.add(data);
466497
}
467498

468499
@Override
469-
public void send(Set<ResponseBodyEmitter.DataWithMediaType> items) {
500+
public void send(Set<ResponseBodyEmitter.DataWithMediaType> items) throws IOException {
470501
items.forEach(item -> this.values.add(item.getData()));
471502
}
472503

473504
@Override
474505
public void complete() {
506+
this.handlingStatus = HandlingStatus.SUCCESS;
475507
}
476508

477509
@Override
478510
public void completeWithError(Throwable failure) {
511+
this.handlingStatus = HandlingStatus.ERROR;
512+
this.failure = failure;
479513
}
480514

481515
@Override
@@ -491,6 +525,22 @@ public void onCompletion(Runnable callback) {
491525
}
492526
}
493527

528+
private enum HandlingStatus {
529+
SUCCESS,ERROR
530+
}
531+
532+
private static class ErroringEmitterHandler extends EmitterHandler {
533+
@Override
534+
public void send(Object data, MediaType mediaType) throws IOException {
535+
throw new IOException();
536+
}
537+
538+
@Override
539+
public void send(Set<ResponseBodyEmitter.DataWithMediaType> items) throws IOException {
540+
throw new IOException();
541+
}
542+
}
543+
494544
private static class Bar {
495545

496546
private final String value;

spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/ResponseBodyEmitterTests.java

-17
Original file line numberDiff line numberDiff line change
@@ -148,23 +148,6 @@ void sendWithError() throws Exception {
148148
verifyNoMoreInteractions(this.handler);
149149
}
150150

151-
@Test // gh-30687
152-
void completeIgnoredAfterIOException() throws Exception {
153-
this.emitter.initialize(this.handler);
154-
verify(this.handler).onTimeout(any());
155-
verify(this.handler).onError(any());
156-
verify(this.handler).onCompletion(any());
157-
verifyNoMoreInteractions(this.handler);
158-
159-
willThrow(new IOException()).given(this.handler).send("foo", MediaType.TEXT_PLAIN);
160-
assertThatIOException().isThrownBy(() -> this.emitter.send("foo", MediaType.TEXT_PLAIN));
161-
verify(this.handler).send("foo", MediaType.TEXT_PLAIN);
162-
verifyNoMoreInteractions(this.handler);
163-
164-
this.emitter.complete();
165-
verifyNoMoreInteractions(this.handler);
166-
}
167-
168151
@Test // gh-30687
169152
void completeAfterNonIOException() throws Exception {
170153
this.emitter.initialize(this.handler);

0 commit comments

Comments
 (0)