diff --git a/spring-test/src/main/java/org/springframework/mock/web/MockHttpServletRequest.java b/spring-test/src/main/java/org/springframework/mock/web/MockHttpServletRequest.java index 0880440d02..37256c15ef 100644 --- a/spring-test/src/main/java/org/springframework/mock/web/MockHttpServletRequest.java +++ b/spring-test/src/main/java/org/springframework/mock/web/MockHttpServletRequest.java @@ -44,6 +44,8 @@ import java.util.TimeZone; import java.util.stream.Collectors; import jakarta.servlet.AsyncContext; +import jakarta.servlet.AsyncEvent; +import jakarta.servlet.AsyncListener; import jakarta.servlet.DispatcherType; import jakarta.servlet.RequestDispatcher; import jakarta.servlet.ServletConnection; @@ -920,7 +922,19 @@ public class MockHttpServletRequest implements HttpServletRequest { public AsyncContext startAsync(ServletRequest request, @Nullable ServletResponse response) { Assert.state(this.asyncSupported, "Async not supported"); this.asyncStarted = true; - this.asyncContext = new MockAsyncContext(request, response); + MockAsyncContext newAsyncContext = new MockAsyncContext(request, response); + if (this.asyncContext != null) { + try { + AsyncEvent startEvent = new AsyncEvent(newAsyncContext); + for (AsyncListener asyncListener : this.asyncContext.getListeners()) { + asyncListener.onStartAsync(startEvent); + } + } + catch (IOException ex) { + // ignore failures + } + } + this.asyncContext = newAsyncContext; return this.asyncContext; } diff --git a/spring-test/src/test/java/org/springframework/mock/web/MockHttpServletRequestTests.java b/spring-test/src/test/java/org/springframework/mock/web/MockHttpServletRequestTests.java index 963846482b..2dd438f31b 100644 --- a/spring-test/src/test/java/org/springframework/mock/web/MockHttpServletRequestTests.java +++ b/spring-test/src/test/java/org/springframework/mock/web/MockHttpServletRequestTests.java @@ -30,6 +30,9 @@ import java.util.List; import java.util.Locale; import java.util.Map; +import jakarta.servlet.AsyncContext; +import jakarta.servlet.AsyncEvent; +import jakarta.servlet.AsyncListener; import jakarta.servlet.http.Cookie; import org.junit.jupiter.api.Test; @@ -663,6 +666,44 @@ class MockHttpServletRequestTests { request.getDateHeader(HttpHeaders.IF_MODIFIED_SINCE)); } + @Test + void shouldRejectAsyncStartsIfUnsupported() { + assertThat(request.isAsyncStarted()).isFalse(); + assertThatIllegalStateException().isThrownBy(request::startAsync); + } + + @Test + void startAsyncShouldUpdateRequestState() { + assertThat(request.isAsyncStarted()).isFalse(); + request.setAsyncSupported(true); + AsyncContext asyncContext = request.startAsync(); + assertThat(request.isAsyncStarted()).isTrue(); + } + + @Test + void shouldNotifyAsyncListeners() { + request.setAsyncSupported(true); + AsyncContext asyncContext = request.startAsync(); + TestAsyncListener testAsyncListener = new TestAsyncListener(); + asyncContext.addListener(testAsyncListener); + asyncContext.complete(); + assertThat(testAsyncListener.events).hasSize(1); + assertThat(testAsyncListener.events.get(0)).extracting("name").isEqualTo("onComplete"); + } + + @Test + void shouldNotifyAsyncListenersWhenNewAsyncStarted() { + request.setAsyncSupported(true); + AsyncContext asyncContext = request.startAsync(); + TestAsyncListener testAsyncListener = new TestAsyncListener(); + asyncContext.addListener(testAsyncListener); + AsyncContext newAsyncContext = request.startAsync(); + assertThat(testAsyncListener.events).hasSize(1); + ListenerEvent listenerEvent = testAsyncListener.events.get(0); + assertThat(listenerEvent).extracting("name").isEqualTo("onStartAsync"); + assertThat(listenerEvent.event.getAsyncContext()).isEqualTo(newAsyncContext); + } + private void assertEqualEnumerations(Enumeration enum1, Enumeration enum2) { int count = 0; while (enum1.hasMoreElements()) { @@ -672,4 +713,31 @@ class MockHttpServletRequestTests { } } + static class TestAsyncListener implements AsyncListener { + + List events = new ArrayList<>(); + + @Override + public void onComplete(AsyncEvent asyncEvent) throws IOException { + this.events.add(new ListenerEvent("onComplete", asyncEvent)); + } + + @Override + public void onTimeout(AsyncEvent asyncEvent) throws IOException { + this.events.add(new ListenerEvent("onTimeout", asyncEvent)); + } + + @Override + public void onError(AsyncEvent asyncEvent) throws IOException { + this.events.add(new ListenerEvent("onError", asyncEvent)); + } + + @Override + public void onStartAsync(AsyncEvent asyncEvent) throws IOException { + this.events.add(new ListenerEvent("onStartAsync", asyncEvent)); + } + } + + record ListenerEvent(String name, AsyncEvent event) {} + } diff --git a/spring-web/src/main/java/org/springframework/web/filter/ServerHttpObservationFilter.java b/spring-web/src/main/java/org/springframework/web/filter/ServerHttpObservationFilter.java index cee9c9e7ce..c28e761592 100644 --- a/spring-web/src/main/java/org/springframework/web/filter/ServerHttpObservationFilter.java +++ b/spring-web/src/main/java/org/springframework/web/filter/ServerHttpObservationFilter.java @@ -119,13 +119,13 @@ public class ServerHttpObservationFilter extends OncePerRequestFilter { throw ex; } finally { - // If async is started, register a listener for completion notification. - if (request.isAsyncStarted()) { + // If async is started during the first dispatch, register a listener for completion notification. + if (request.isAsyncStarted() && request.getDispatcherType() == DispatcherType.REQUEST) { request.getAsyncContext().addListener(new ObservationAsyncListener(observation)); } // scope is opened for ASYNC dispatches, but the observation will be closed // by the async listener. - else if (request.getDispatcherType() != DispatcherType.ASYNC){ + else if (!isAsyncDispatch(request)) { Throwable error = fetchException(request); if (error != null) { observation.error(error); @@ -180,6 +180,7 @@ public class ServerHttpObservationFilter extends OncePerRequestFilter { @Override public void onStartAsync(AsyncEvent event) { + event.getAsyncContext().addListener(this); } @Override diff --git a/spring-web/src/test/java/org/springframework/web/filter/ServerHttpObservationFilterTests.java b/spring-web/src/test/java/org/springframework/web/filter/ServerHttpObservationFilterTests.java index 78a895fe77..0905455997 100644 --- a/spring-web/src/test/java/org/springframework/web/filter/ServerHttpObservationFilterTests.java +++ b/spring-web/src/test/java/org/springframework/web/filter/ServerHttpObservationFilterTests.java @@ -22,11 +22,16 @@ import io.micrometer.observation.Observation; import io.micrometer.observation.ObservationRegistry; import io.micrometer.observation.tck.TestObservationRegistry; import io.micrometer.observation.tck.TestObservationRegistryAssert; +import jakarta.servlet.AsyncContext; import jakarta.servlet.AsyncEvent; import jakarta.servlet.AsyncListener; import jakarta.servlet.DispatcherType; +import jakarta.servlet.Filter; +import jakarta.servlet.FilterChain; import jakarta.servlet.RequestDispatcher; import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; import jakarta.servlet.http.HttpServlet; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -149,6 +154,21 @@ class ServerHttpObservationFilterTests { assertThatHttpObservation().hasLowCardinalityKeyValue("outcome", "SUCCESS").hasBeenStopped(); } + @Test + void shouldRegisterListenerForAsyncStarts() throws Exception { + CustomAsyncFilter customAsyncFilter = new CustomAsyncFilter(); + this.mockFilterChain = new MockFilterChain(new NoOpServlet(), customAsyncFilter); + this.request.setAsyncSupported(true); + this.request.setDispatcherType(DispatcherType.REQUEST); + this.filter.doFilter(this.request, this.response, this.mockFilterChain); + customAsyncFilter.asyncContext.dispatch(); + this.request.setDispatcherType(DispatcherType.ASYNC); + AsyncContext newAsyncContext = this.request.startAsync(); + newAsyncContext.complete(); + + assertThatHttpObservation().hasLowCardinalityKeyValue("outcome", "SUCCESS").hasBeenStopped(); + } + @Test void shouldCloseObservationAfterAsyncError() throws Exception { this.request.setAsyncSupported(true); @@ -210,6 +230,29 @@ class ServerHttpObservationFilterTests { Assert.notNull(response, "response must not be null"); response.setHeader("X-Trace-Id", "badc0ff33"); } + + } + + @SuppressWarnings("serial") + static class NoOpServlet extends HttpServlet { + + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { + + } + + } + + static class CustomAsyncFilter implements Filter { + + AsyncContext asyncContext; + + @Override + public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException { + this.asyncContext = servletRequest.startAsync(); + filterChain.doFilter(servletRequest, servletResponse); + } + } } diff --git a/spring-web/src/testFixtures/java/org/springframework/web/testfixture/servlet/MockHttpServletRequest.java b/spring-web/src/testFixtures/java/org/springframework/web/testfixture/servlet/MockHttpServletRequest.java index 1d2381d495..d4377476ac 100644 --- a/spring-web/src/testFixtures/java/org/springframework/web/testfixture/servlet/MockHttpServletRequest.java +++ b/spring-web/src/testFixtures/java/org/springframework/web/testfixture/servlet/MockHttpServletRequest.java @@ -44,6 +44,8 @@ import java.util.TimeZone; import java.util.stream.Collectors; import jakarta.servlet.AsyncContext; +import jakarta.servlet.AsyncEvent; +import jakarta.servlet.AsyncListener; import jakarta.servlet.DispatcherType; import jakarta.servlet.RequestDispatcher; import jakarta.servlet.ServletConnection; @@ -920,7 +922,19 @@ public class MockHttpServletRequest implements HttpServletRequest { public AsyncContext startAsync(ServletRequest request, @Nullable ServletResponse response) { Assert.state(this.asyncSupported, "Async not supported"); this.asyncStarted = true; - this.asyncContext = new MockAsyncContext(request, response); + MockAsyncContext newAsyncContext = new MockAsyncContext(request, response); + if (this.asyncContext != null) { + try { + AsyncEvent startEvent = new AsyncEvent(newAsyncContext); + for (AsyncListener asyncListener : this.asyncContext.getListeners()) { + asyncListener.onStartAsync(startEvent); + } + } + catch (IOException ex) { + // ignore failures + } + } + this.asyncContext = newAsyncContext; return this.asyncContext; }