From 9aa48c32962f570f15faaf2160c3e4c5ea410199 Mon Sep 17 00:00:00 2001 From: moyis Date: Mon, 13 Jul 2026 13:57:56 -0300 Subject: [PATCH] fix: stop WireMock servers when context closes during AOT processing During Spring test AOT processing (SpringBootTestAotProcessor), the test application context is loaded and closed outside the JUnit lifecycle. The ContextClosedEvent is never delivered in that case (the event multicaster is not initialized by refreshForAotProcessing), so the WireMock servers were never stopped and their non-daemon Jetty threads kept the AOT processing JVM alive forever, hanging processTestAot. Singletons are still destroyed when that context is closed, so each started server is now also registered as a disposable bean. The existing ContextClosedEvent listener is kept, and both paths are guarded against double-stop. Fixes #199 --- .../internal/WireMockServerCreator.java | 16 ++++- .../test/java/usecases/AotProcessingTest.java | 58 +++++++++++++++++++ 2 files changed, 72 insertions(+), 2 deletions(-) create mode 100644 wiremock-spring-boot/src/test/java/usecases/AotProcessingTest.java diff --git a/wiremock-spring-boot/src/main/java/org/wiremock/spring/internal/WireMockServerCreator.java b/wiremock-spring-boot/src/main/java/org/wiremock/spring/internal/WireMockServerCreator.java index 1ef56e6..fe7eb37 100644 --- a/wiremock-spring-boot/src/main/java/org/wiremock/spring/internal/WireMockServerCreator.java +++ b/wiremock-spring-boot/src/main/java/org/wiremock/spring/internal/WireMockServerCreator.java @@ -16,6 +16,7 @@ import org.junit.platform.commons.util.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.support.DefaultSingletonBeanRegistry; import org.springframework.boot.test.util.TestPropertyValues; import org.springframework.context.ConfigurableApplicationContext; import org.springframework.context.event.ContextClosedEvent; @@ -83,11 +84,15 @@ public WireMockServer createWireMockServer( context.addApplicationListener( event -> { if (event instanceof ContextClosedEvent) { - this.logger.info("Stopping WireMockServer with name '{}'", options.name()); - newServer.stop(); + this.stopWireMockServer(options.name(), newServer); } }); + if (context.getBeanFactory() instanceof DefaultSingletonBeanRegistry singletonBeanRegistry) { + singletonBeanRegistry.registerDisposableBean( + options.name() + "-shutdown", () -> this.stopWireMockServer(options.name(), newServer)); + } + if (httpEnabled) { Arrays.stream(options.baseUrlProperties()) .filter(StringUtils::isNotBlank) @@ -150,6 +155,13 @@ public WireMockServer createWireMockServer( return newServer; } + private void stopWireMockServer(final String name, final WireMockServer server) { + if (server.isRunning()) { + this.logger.info("Stopping WireMockServer with name '{}'", name); + server.stop(); + } + } + private void configureMappings(ConfigureWireMock options, WireMockConfiguration serverOptions) { boolean isFilesUnderDirectorySupplied = options.filesUnderDirectory().length != 0; boolean isFilesUnderClasspathSupplied = !options.filesUnderClasspath().isEmpty(); diff --git a/wiremock-spring-boot/src/test/java/usecases/AotProcessingTest.java b/wiremock-spring-boot/src/test/java/usecases/AotProcessingTest.java new file mode 100644 index 0000000..81aa24e --- /dev/null +++ b/wiremock-spring-boot/src/test/java/usecases/AotProcessingTest.java @@ -0,0 +1,58 @@ +package usecases; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.springframework.aot.generate.InMemoryGeneratedFiles; +import org.springframework.aot.hint.RuntimeHints; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.aot.TestContextAotGenerator; +import org.wiremock.spring.EnableWireMock; + +class AotProcessingTest { + + @SpringBootTest + @EnableWireMock + static class AotProcessedTest {} + + @Test + void wireMockIsStoppedWhenAotProcessingClosesContext() throws InterruptedException { + final Set poolsBefore = + this.nonDaemonJettyThreads().stream() + .map(this::jettyThreadPoolName) + .collect(Collectors.toSet()); + + new TestContextAotGenerator(new InMemoryGeneratedFiles(), new RuntimeHints(), true) + .processAheadOfTime(Stream.of(AotProcessedTest.class)); + + final Set leaked = this.leakedJettyThreads(poolsBefore); + final long deadline = System.currentTimeMillis() + 10_000; + while (!leaked.isEmpty() && System.currentTimeMillis() < deadline) { + Thread.sleep(100); + leaked.removeIf(thread -> !thread.isAlive()); + } + assertThat(leaked) + .as("non-daemon Jetty threads still running after AOT processing closed the context") + .isEmpty(); + } + + private Set leakedJettyThreads(final Set poolsBefore) { + return this.nonDaemonJettyThreads().stream() + .filter(thread -> !poolsBefore.contains(this.jettyThreadPoolName(thread))) + .collect(Collectors.toSet()); + } + + private Set nonDaemonJettyThreads() { + return Thread.getAllStackTraces().keySet().stream() + .filter(thread -> !thread.isDaemon()) + .filter(thread -> thread.getName().startsWith("qtp")) + .collect(Collectors.toSet()); + } + + private String jettyThreadPoolName(final Thread thread) { + return thread.getName().replaceFirst("-\\d+$", ""); + } +}