From 154ec93cd1178ca619e49bb05a4ae24c8f52a682 Mon Sep 17 00:00:00 2001 From: Meyon Soo Kim <39788337+PreAgile@users.noreply.github.com> Date: Mon, 4 May 2026 16:01:19 +0900 Subject: [PATCH] Skip route refresh after the context starts shutting down CachingRouteLocator listens for RefreshRoutesEvent and delegates to its RouteLocator on every event. RefreshRoutesEvent can be published during the destroy phase of the ApplicationContext -- for example, ConsulServiceRegistry.deregister() runs as a destroy callback and emits one, at which point any bean lookup performed transitively by the delegate fails with BeanCreationNotAllowedException, as in the original report (RouteDefinitionRouteLocator requesting `defaultValidator` to validate RouteDefinitions). AbstractApplicationContext.isActive() cannot guard this path because active is only set to false at the very end of doClose(), after destroyBeans() has already run. The first signal that close has begun is ContextClosedEvent, which doClose() publishes before destroyBeans(). Register a one-shot ContextClosedEvent listener on the ApplicationEventPublisher when it is a ConfigurableApplicationContext, flip a volatile flag from it, and short-circuit onApplicationEvent(RefreshRoutesEvent) when the flag is set. CachingRouteLocatorShutdownTests reproduces the issue deterministically by registering a DisposableBean whose destroy() publishes a RefreshRoutesEvent -- the same timing exercised by the Consul registry shutdown path. Existing CachingRouteLocatorTests keep passing because they wire a lambda as the event publisher, so the new instanceof branch is skipped and the guard remains inactive, preserving the original behaviour for non-context publishers. Fixes gh-2471 Signed-off-by: Meyon Soo Kim <39788337+PreAgile@users.noreply.github.com> --- .../gateway/route/CachingRouteLocator.java | 15 ++++ .../CachingRouteLocatorShutdownTests.java | 85 +++++++++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 spring-cloud-gateway-server-webflux/src/test/java/org/springframework/cloud/gateway/route/CachingRouteLocatorShutdownTests.java diff --git a/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/route/CachingRouteLocator.java b/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/route/CachingRouteLocator.java index e499f6d5e..1edf0ec2d 100644 --- a/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/route/CachingRouteLocator.java +++ b/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/route/CachingRouteLocator.java @@ -35,6 +35,8 @@ import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.ApplicationEventPublisherAware; import org.springframework.context.ApplicationListener; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.event.ContextClosedEvent; import org.springframework.core.Ordered; import org.springframework.core.annotation.AnnotationAwareOrderComparator; @@ -56,6 +58,8 @@ public class CachingRouteLocator private @Nullable ApplicationEventPublisher applicationEventPublisher; + private volatile boolean contextClosing = false; + public CachingRouteLocator(RouteLocator delegate) { this.delegate = delegate; routes = CacheFlux.lookup(cache, CACHE_KEY, Route.class).onCacheMissResume(this::fetch); @@ -85,6 +89,13 @@ public Flux refresh() { @Override public void onApplicationEvent(RefreshRoutesEvent event) { + // gh-2471: skip refresh once the ApplicationContext has started shutting + // down. RefreshRoutesEvent can still be published during shutdown (e.g. + // ConsulServiceRegistry.deregister()), and any bean lookup performed by + // the delegate at that point would fail with BeanCreationNotAllowedException. + if (contextClosing) { + return; + } try { if (this.cache.containsKey(CACHE_KEY) && event.isScoped()) { final Mono> scopedRoutes = fetch(event.getMetadata()).collect(Collectors.toList()) @@ -138,6 +149,10 @@ public int getOrder() { @Override public void setApplicationEventPublisher(ApplicationEventPublisher applicationEventPublisher) { this.applicationEventPublisher = applicationEventPublisher; + if (applicationEventPublisher instanceof ConfigurableApplicationContext context) { + ApplicationListener shutdownListener = event -> this.contextClosing = true; + context.addApplicationListener(shutdownListener); + } } } diff --git a/spring-cloud-gateway-server-webflux/src/test/java/org/springframework/cloud/gateway/route/CachingRouteLocatorShutdownTests.java b/spring-cloud-gateway-server-webflux/src/test/java/org/springframework/cloud/gateway/route/CachingRouteLocatorShutdownTests.java new file mode 100644 index 000000000..4e0a850ad --- /dev/null +++ b/spring-cloud-gateway-server-webflux/src/test/java/org/springframework/cloud/gateway/route/CachingRouteLocatorShutdownTests.java @@ -0,0 +1,85 @@ +/* + * Copyright 2013-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.gateway.route; + +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; + +import org.springframework.beans.factory.DisposableBean; +import org.springframework.cloud.gateway.event.RefreshRoutesEvent; +import org.springframework.context.support.GenericApplicationContext; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Reproduction tests for gh-2471: shutdown can cause BeanCreationNotAllowedException + * because RefreshRoutesEvent listeners (such as CachingRouteLocator) keep delegating to + * RouteLocator beans even after the ApplicationContext has started destroying singletons + * (e.g. ConsulServiceRegistry.deregister() publishing a RefreshRoutesEvent during + * destroy). The expected behaviour is that CachingRouteLocator short-circuits the refresh + * once shutdown has begun, so it does not try to obtain (or recreate) any beans that the + * BeanFactory will refuse to provide. + */ +public class CachingRouteLocatorShutdownTests { + + @Test + public void doesNotRefreshAfterContextHasStartedClosing() { + GenericApplicationContext context = new GenericApplicationContext(); + context.refresh(); + + AtomicInteger fetchCount = new AtomicInteger(); + Route route = Route.async().id("r1").uri("http://localhost").order(0).predicate(exchange -> true).build(); + + RouteLocator delegate = () -> { + fetchCount.incrementAndGet(); + return Flux.just(route); + }; + + CachingRouteLocator locator = new CachingRouteLocator(delegate); + locator.setApplicationEventPublisher(context); + context.addApplicationListener(locator); + + // Prime the cache so the first fetch counts as the baseline (1). + locator.getRoutes().collectList().block(); + int baselineFetches = fetchCount.get(); + assertThat(baselineFetches).isEqualTo(1); + + // Simulate the gh-2471 trigger: a bean (Consul service registry, in the + // real report) publishes a RefreshRoutesEvent from its destroy callback + // while the BeanFactory is already destroying singletons. At that point + // the BeanFactory refuses to create or look up new beans and throws + // BeanCreationNotAllowedException. + context.getDefaultListableBeanFactory().registerDisposableBean("refreshOnShutdown", new DisposableBean() { + @Override + public void destroy() { + context.publishEvent(new RefreshRoutesEvent(this)); + } + }); + + context.close(); + + // With shutdown gating in place, the destroy-phase RefreshRoutesEvent + // must be ignored. Today this assertion fails because the listener + // keeps invoking the delegate even after destroy has begun. + assertThat(fetchCount.get()) + .as("CachingRouteLocator must not delegate to RouteLocator beans during context destruction (gh-2471)") + .isEqualTo(baselineFetches); + } + +}