spring-projects / spring-framework

Spring Framework
https://spring.io/projects/spring-framework
Apache License 2.0
56.7k stars 38.14k forks source link

SseEmitter: connection closed after first event #25987

Closed reta closed 4 years ago

reta commented 4 years ago

It seems like there is a regression introduced into SseEmitter in latest 5.2.10.RELEASE (apparently https://github.com/spring-projects/spring-framework/issues/25442), it now returns to the client only first SSE event.

How to reproduce

package com.example.sse.emmiter;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

import org.apache.commons.lang3.builder.EqualsBuilder;
import org.apache.commons.lang3.builder.HashCodeBuilder;
import org.apache.commons.lang3.builder.ToStringBuilder;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter.SseEventBuilder;

@SpringBootApplication
public class SseEmitterRegressionApplication {
    @RestController
    @EnableAutoConfiguration
    static class LibraryController {
        @GetMapping("/sse")
        public SseEmitter streamSseMvc() {
            final SseEmitter emitter = new SseEmitter();
            final ExecutorService sseMvcExecutor = Executors.newSingleThreadExecutor();

            sseMvcExecutor.execute(() -> {
                try {
                    for (int eventId = 1; eventId <= 5; ++eventId) {
                        SseEventBuilder event = SseEmitter.event()
                            .id(Integer.toString(eventId))
                            .data(new Book("New Book #" + eventId, "Author #" + eventId), MediaType.APPLICATION_JSON)
                            .name("book");
                        emitter.send(event);
                        Thread.sleep(100);
                    }
                    emitter.complete();
                } catch (Exception ex) {
                    emitter.completeWithError(ex);
                }
            });

            return emitter;
        }
    }

    static class Book {
        private String title;
        private String author;

        public Book() {
        }

        public Book(final String title, final String author) {
            this.setTitle(title);
            this.setAuthor(author);
        }

        public String getTitle() {
            return title;
        }

        public void setTitle(String title) {
            this.title = title;
        }

        public String getAuthor() {
            return author;
        }

        public void setAuthor(String author) {
            this.author = author;
        }

        @Override
        public int hashCode() {
            return HashCodeBuilder.reflectionHashCode(this);
        }

        @Override
        public boolean equals(Object obj) {
            return EqualsBuilder.reflectionEquals(this, obj);
        }

        @Override
        public String toString() {
            return ToStringBuilder.reflectionToString(this);
        }
    }

    public static void main(String[] args) {
        SpringApplication.run(SseEmitterRegressionApplication.class, args);
    }
}
$ curl http://localhost:8080/sse -iv

> GET /sse HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.71.1
> Accept: */*
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 200
< Content-Type: text/event-stream
< Transfer-Encoding: chunked
< Date: Wed, 28 Oct 2020 22:09:00 GMT
<
{ [15 bytes data]
100   335    0   335    0     0    589      0 --:--:-- --:--:-- --:--:--   590HTTP/1.1 200

Content-Type: text/event-stream
Transfer-Encoding: chunked
Date: Wed, 28 Oct 2020 22:09:00 GMT

id:1
data:{"title":"New Book #1","author":"Author #1"}
event:book

id:2
data:{"title":"New Book #2","author":"Author #2"}
event:book

id:3
data:{"title":"New Book #3","author":"Author #3"}
event:book

id:4
data:{"title":"New Book #4","author":"Author #4"}
event:book

id:5
data:{"title":"New Book #5","author":"Author #5"}
event:book
$ curl http://localhost:8080/sse -iv

> GET /sse HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.71.1
> Accept: */*
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 200
< Content-Type: text/event-stream
< Transfer-Encoding: chunked
< Date: Wed, 28 Oct 2020 22:10:33 GMT
<
{ [15 bytes data]
100    54    0    54    0     0   2250      0 --:--:-- --:--:-- --:--:--  2250HTTP/1.1 200
Content-Type: text/event-stream
Transfer-Encoding: chunked
Date: Wed, 28 Oct 2020 22:10:33 GMT

id:1
data:{"title":"New Book #1","author":"Author #1"}

Reproducible all the time. Please advice if this is a regression or SseEmitter semantics has changed (would appreciate documentation pointers) or more details are needed, thank you.

rstoyanchev commented 4 years ago

The regression is due to optimizations in Jackson codecs and converters, issue #25910.

reta commented 4 years ago

Thanks a lot for quick update, @rstoyanchev

christophejan commented 4 years ago

It's look that there is the same regression with controller producing multipart. The server response is truncated after any jackson part body (the boundary after this part is missing, all remaining parts are ignored).

rstoyanchev commented 4 years ago

Yes this will impact all cases in Spring MVC where Jackson is used to write but the response needs to remain open.

rstoyanchev commented 4 years ago

This should be fixed now for 5.2.11 and 5.3.1.

In the mean time as a workaround you could disable the JsonGenerator.Feature.AUTO_CLOSE_TARGET on the ObjectMapper. For example in Spring Boot:

@Bean
public Jackson2ObjectMapperBuilderCustomizer om() {
    return builder -> builder.featuresToDisable(JsonGenerator.Feature.AUTO_CLOSE_TARGET);
}