Describe the bug
The reset-to-defaults logic that #1680 added to ConfigurationPropertiesRebinder.resetProperties in spring-cloud-context 5.0.2 has three problems. We found all of them with org.infinispan:infinispan-spring-boot4-starter-remote:16.2.3 (InfinispanRemoteConfigurationProperties):
| # |
Problem |
Impact |
| 1 |
StackOverflowError when a read-only getter returns a new object on every call |
Every refresh fails: POST /actuator/refresh, ContextRefresher.refresh(), ConfigurationPropertiesRebinder.rebind() |
| 2 |
Unmodifiable collections/maps: clear() throws UnsupportedOperationException |
Removed entries survive the refresh, and nothing is logged at the default log level |
| 3 |
Setters that reject null: setXxx(null) throws, e.g. NullPointerException |
Removed properties keep their old value, and nothing is logged at the default log level |
Versions:
Sample
Reproducer project (Maven, no Infinispan server needed): spring-cloud-refresh-stackoverflow-repro.zip
Setup for all steps below:
- Download and unzip
spring-cloud-refresh-stackoverflow-repro.zip
cd spring-cloud-refresh-stackoverflow-repro
mvn test runs everything. Each problem below also has its own command.
To test another spring-cloud-context version, add -Dspring-cloud-commons.version=<version>. For snapshots, also add -Pspring-snapshots, which adds https://repo.spring.io/snapshot.
| spring-cloud-context |
Problem 1 |
Problem 2 |
Problem 3 |
| 5.0.1 |
✅ passes |
➖ no reset at all (#1133) |
➖ no reset at all (#1133) |
| 5.0.2 |
❌ fails |
❌ fails |
❌ fails |
| 5.0.3 |
❌ fails |
❌ fails |
❌ fails |
| 5.1.0-M1 |
❌ fails |
❌ fails |
❌ fails |
| 5.0.4-SNAPSHOT (2026-09-29) |
❌ fails |
❌ fails |
❌ fails |
| 5.1.0-SNAPSHOT (2026-09-29) |
❌ fails |
❌ fails |
❌ fails |
Problem 1 is a regression from 5.0.1. Problems 2 and 3 aren't regressions, because 5.0.1 didn't reset removed properties at all. They are gaps in the feature #1680 added.
Problem 1: StackOverflowError for getters that return a new instance per call
Description
If a @ConfigurationProperties bean has a read-only getter that returns a new object on every call, and that object has a readable property that again returns a new object, resetProperties recurses until the stack overflows. The identity-based visited set added for #1698 never matches, because every object is new. InfinispanRemoteConfigurationProperties has exactly this shape.
Minimal bean (FluentProperties in the reproducer):
@ConfigurationProperties("fluent")
public class FluentProperties {
private String name = "default";
// getter + setter for name
public Settings getSettings() { // read-only, new instance per call
return new Settings();
}
public static class Settings {
private boolean protect; // same-named field => Spring exposes protect() as a record-style property
public Settings protect() { // new instance per call
Settings copy = new Settings();
copy.protect = true;
return copy;
}
}
}
Steps to reproduce
- Register
InfinispanRemoteConfigurationProperties or FluentProperties via @EnableConfigurationProperties
- Call
ContextRefresher.refresh() (the same code path as POST /actuator/refresh) or ConfigurationPropertiesRebinder.rebind(beanName)
- In the reproducer:
mvn test -Dtest=RefreshStackOverflowTests
Expected
The refresh completes and the beans are rebound.
Actual
All 3 tests fail (contextRefreshDoesNotFail, rebindInfinispanRemoteConfigurationProperties, rebindPropertiesWithFluentAccessorReturningNewInstance):
Caused by: java.lang.StackOverflowError
at java.base/java.lang.reflect.InvocationTargetException.<init>(Unknown Source)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(Unknown Source)
at java.base/java.lang.reflect.Method.invoke(Unknown Source)
at org.springframework.beans.BeanWrapperImpl$BeanPropertyHandler.getValue(BeanWrapperImpl.java:270)
at org.springframework.beans.AbstractNestablePropertyAccessor.getPropertyValue(AbstractNestablePropertyAccessor.java:623)
at org.springframework.beans.AbstractNestablePropertyAccessor.getPropertyValue(AbstractNestablePropertyAccessor.java:611)
at org.springframework.cloud.context.properties.ConfigurationPropertiesRebinder.resetProperties(ConfigurationPropertiesRebinder.java:290)
at org.springframework.cloud.context.properties.ConfigurationPropertiesRebinder.resetProperties(ConfigurationPropertiesRebinder.java:305)
at org.springframework.cloud.context.properties.ConfigurationPropertiesRebinder.resetProperties(ConfigurationPropertiesRebinder.java:305)
at org.springframework.cloud.context.properties.ConfigurationPropertiesRebinder.resetProperties(ConfigurationPropertiesRebinder.java:305)
...
Problem 2: unmodifiable collections/maps are silently not reset
Description
For a read-only Collection / Map property, resetProperties calls clear() and then addAll(..) / putAll(..). Read-only getters often return an unmodifiable view, e.g. Collections.unmodifiableMap(..), List.copyOf(..) or Map.of(). For these, clear() throws UnsupportedOperationException. The catch (Exception) block logs it only at DEBUG, so the property is silently not reset.
Minimal bean (ReadOnlyViewProperties in the reproducer). It has the same shape as InfinispanRemoteConfigurationProperties#setCluster(Map): bound through a write-only property, exposed through a read-only view.
@ConfigurationProperties("readonly")
public class ReadOnlyViewProperties {
private final Map<String, String> labels = new LinkedHashMap<>();
public Map<String, String> getLabels() { // read-only, unmodifiable view
return Collections.unmodifiableMap(this.labels);
}
public void setLabel(Map<String, String> label) { // bound from readonly.label.<key>
this.labels.putAll(label);
}
}
Steps to reproduce
- Put
readonly.label.a=1 and readonly.label.b=2 into the Environment and call rebinder.rebind(beanName) → getLabels() is {a=1, b=2}
- Remove
readonly.label.b from the Environment and call rebinder.rebind(beanName) again
- In the reproducer:
mvn test -Dtest=RefreshUnmodifiableCollectionTests. DEBUG logging for ConfigurationPropertiesRebinder is enabled in this test class.
Expected
getLabels() is {a=1}.
Actual
getLabels() is still {a=1, b=2} (removedEntryIsResetForUnmodifiableView fails), and the exception only appears at DEBUG:
DEBUG ... ConfigurationPropertiesRebinder : Failed to reset property 'labels' on com.example.repro.ReadOnlyViewProperties
java.lang.UnsupportedOperationException
at java.base/java.util.Collections$UnmodifiableMap.clear(Collections.java:1675)
The control test removedEntryIsResetForMutableMap passes. It uses MutableViewProperties, which is identical except that the getter returns the mutable map.
With Infinispan, AttributeSet#attributes() returns Collections.unmodifiableCollection(..). A single rebind of InfinispanRemoteConfigurationProperties logs Failed to reset property 'attributes' on org.infinispan.commons.configuration.attributes.AttributeSet … UnsupportedOperationException 1,955 times (once per recursion level of problem 1) before the StackOverflowError.
Problem 3: properties with a setter that rejects null are silently not reset
Description
For a writable property, resetProperties calls the setter with the value from a fresh default instance. When the default is null, that means setXxx(null). Before #1680, setters only received values that were actually bound, so many don't accept null:
- they normalize or convert the input:
value.strip(), URI.create(value), Duration.parse(value)
- they call
Objects.requireNonNull(..) / Assert.notNull(..)
- they write into a
java.util.Properties / ConcurrentHashMap
The exception is caught by the same catch (Exception) block and logged only at DEBUG, so the property is silently not reset.
Minimal bean (NullRejectingSetterProperties in the reproducer):
@ConfigurationProperties("nullrejecting")
public class NullRejectingSetterProperties {
private String endpoint; // default: null
public String getEndpoint() { return this.endpoint; }
public void setEndpoint(String endpoint) {
this.endpoint = endpoint.strip(); // NullPointerException for null
}
}
Steps to reproduce
- Put
nullrejecting.endpoint=https://old.example.com into the Environment and call rebinder.rebind(beanName) → getEndpoint() is "https://old.example.com"
- Remove
nullrejecting.endpoint from the Environment and call rebinder.rebind(beanName) again
- In the reproducer:
mvn test -Dtest=RefreshNullRejectingSetterTests. DEBUG logging for ConfigurationPropertiesRebinder is enabled in this test class.
Expected
getEndpoint() is null.
Actual
getEndpoint() is still "https://old.example.com" (removedPropertyIsResetForNullRejectingSetter fails), and the exception only appears at DEBUG:
DEBUG ... ConfigurationPropertiesRebinder : Failed to reset property 'endpoint' on com.example.repro.NullRejectingSetterProperties
org.springframework.beans.MethodInvocationException: Property 'endpoint' threw exception: java.lang.NullPointerException: Cannot invoke "String.strip()" because "endpoint" is null
The control test removedPropertyIsResetForPlainSetter passes. It uses plainEndpoint on the same bean, which has a plain setter and is reset to null correctly.
With Infinispan, the reset fails this way for URI, SSLProtocol, authUsername, authPassword, authRealm and authToken. Their setters write into a java.util.Properties, e.g. setURI(uri) → props.setProperty(URI, uri) → ConcurrentHashMap.putVal →
With Infinispan, the reset fails this way for URI, SSLProtocol, authUsername, authPassword, authRealm and authToken. Their setters write into a java.util.Properties, e.g. setURI(uri) → props.setProperty(URI, uri) → ConcurrentHashMap.putVal → NullPointerException. For Infinispan this happens to cause no harm: the reset also clears the read-only getProperties() (the same Properties instance), so the values are reset anyway. We checked this with the workaround for problem 1 in place.
Root cause analysis
Problem 1. resetProperties recurses into every readable, non-writable, non-simple property. The only thing that stops the recursion is the identity-based visited set added for #1698, so it only works when the object graph is made of stable instances. With Infinispan, the path is:
InfinispanRemoteConfigurationProperties#getConfigurationBuilder() is read-only and returns new ConfigurationBuilder() on each call. Its type is in org.infinispan..., so isResettableNestedType(..) returns true.
ConfigurationBuilder → statistics → attributes (org.infinispan.commons.configuration.attributes.AttributeSet).
AttributeSet has a field boolean protect and a method AttributeSet protect() that returns a new protected copy on every call. Spring Framework's CachedIntrospectionResults treats a method with no get prefix as a readable "record-style" property when a field with the same name exists.
resetProperties descends into attributes.protect.protect.protect…. Each level is a new instance, so visited.add(..) is always true → StackOverflowError.
props.configurationBuilder -> ConfigurationBuilder@63001505
props.configurationBuilder.statistics -> StatisticsConfigurationBuilder@867148091
props.configurationBuilder.statistics.attributes -> AttributeSet@634540230
props.configurationBuilder.statistics.attributes.protect -> AttributeSet@2129221032
props.configurationBuilder.statistics.attributes.protect.protect -> AttributeSet@1472465
... (never terminates)
The subtree being walked is a throw-away object built by a getter, so resetting it has no effect on the bean anyway. #1734 doesn't help because InfinispanRemoteConfigurationProperties has only a no-argument constructor, and #1740 only changes how never-reset-nested-types is applied.
Problems 2 and 3. Both come from this part of resetProperties:
try {
if (target.isWritableProperty(propertyName) && defaultsWrapper.isReadableProperty(propertyName)) {
Object defaultValue = defaultsWrapper.getPropertyValue(propertyName);
target.setPropertyValue(propertyName, defaultValue); // problem 3: setXxx(null)
}
else if (...) {
...
if (value instanceof Collection collection) {
collection.clear(); // problem 2: unmodifiable -> UnsupportedOperationException
...
}
else if (value instanceof Map map) {
map.clear(); // problem 2
...
}
...
}
}
catch (Exception ex) {
if (logger.isDebugEnabled()) {
logger.debug("Failed to reset property '" + propertyName + "' on " + ...); // swallowed
}
}
A failed reset is treated as harmless. It isn't: the bean keeps the value the refresh was supposed to remove.
Related issues
All three problems come from the reset-to-defaults logic introduced in #1680 (5.0.2):
Workaround
Problem 1:
spring.cloud.refresh.never-reset-nested-types=org.infinispan.
spring.cloud.refresh.never-reset-nested-types=org.infinispan.
The reproducer's RefreshWorkaroundTests shows that this works on 5.0.3 and later (the property doesn't exist in 5.0.2). spring.cloud.refresh.never-refreshable also avoids the error, but then the bean is not rebound at all.
Problems 2 and 3: no workaround, short of changing the bean. Make the getter return the mutable collection/map (problem 2), or make the setter accept null (problem 3).
Possible fixes
Problem 1:
- Only recurse into a read-only property if it is backed by a stable instance. For example, read it twice and descend only if
value == target.getPropertyValue(name). A getter that returns a new object each time can't hold state that needs a reset.
- And/or don't descend through record-style / fluent accessors, and add a recursion depth limit as a safety net.
Problems 2 and 3:
- Don't swallow reset failures at DEBUG. If a property can't be reset, the bean silently keeps stale configuration, so log a WARN (once per bean/property) that names the property and says it may keep its previous value.
- Problem 2: handle
UnsupportedOperationException from clear() explicitly. Apply the same stable-instance check as for problem 1, because an unmodifiable copy such as List.copyOf(..) or Map.of() can't hold state that needs a reset either.
- Problem 3: skip
setXxx(defaultValue) when the current value already equals the default (ObjectUtils.nullSafeEquals). That avoids needless setter calls. It doesn't help when the value really changed, so the WARN is still needed there.
General: given the number of regressions caused by #1680 (#1698, #1716, #1727, #1733, and this issue), consider making the reset-to-defaults behaviour opt-in.
Describe the bug
The reset-to-defaults logic that #1680 added to
ConfigurationPropertiesRebinder.resetPropertiesin spring-cloud-context 5.0.2 has three problems. We found all of them withorg.infinispan:infinispan-spring-boot4-starter-remote:16.2.3(InfinispanRemoteConfigurationProperties):StackOverflowErrorwhen a read-only getter returns a new object on every callPOST /actuator/refresh,ContextRefresher.refresh(),ConfigurationPropertiesRebinder.rebind()clear()throwsUnsupportedOperationExceptionnull:setXxx(null)throws, e.g.NullPointerExceptionVersions:
5.0.4-SNAPSHOT/5.1.0-SNAPSHOTbuilds of 2026-09-29 (which include Skip reset to default functionality when a bean contains more than one constructor #1734 and Apply never-reset-nested-types to writable properties #1740)Sample
Reproducer project (Maven, no Infinispan server needed): spring-cloud-refresh-stackoverflow-repro.zip
Setup for all steps below:
spring-cloud-refresh-stackoverflow-repro.zipcd spring-cloud-refresh-stackoverflow-repromvn testruns everything. Each problem below also has its own command.To test another spring-cloud-context version, add
-Dspring-cloud-commons.version=<version>. For snapshots, also add-Pspring-snapshots, which adds https://repo.spring.io/snapshot.Problem 1 is a regression from 5.0.1. Problems 2 and 3 aren't regressions, because 5.0.1 didn't reset removed properties at all. They are gaps in the feature #1680 added.
Problem 1:
StackOverflowErrorfor getters that return a new instance per callDescription
If a
@ConfigurationPropertiesbean has a read-only getter that returns a new object on every call, and that object has a readable property that again returns a new object,resetPropertiesrecurses until the stack overflows. The identity-basedvisitedset added for #1698 never matches, because every object is new.InfinispanRemoteConfigurationPropertieshas exactly this shape.Minimal bean (
FluentPropertiesin the reproducer):Steps to reproduce
InfinispanRemoteConfigurationPropertiesorFluentPropertiesvia@EnableConfigurationPropertiesContextRefresher.refresh()(the same code path asPOST /actuator/refresh) orConfigurationPropertiesRebinder.rebind(beanName)mvn test -Dtest=RefreshStackOverflowTestsExpected
The refresh completes and the beans are rebound.
Actual
All 3 tests fail (
contextRefreshDoesNotFail,rebindInfinispanRemoteConfigurationProperties,rebindPropertiesWithFluentAccessorReturningNewInstance):Problem 2: unmodifiable collections/maps are silently not reset
Description
For a read-only
Collection/Mapproperty,resetPropertiescallsclear()and thenaddAll(..)/putAll(..). Read-only getters often return an unmodifiable view, e.g.Collections.unmodifiableMap(..),List.copyOf(..)orMap.of(). For these,clear()throwsUnsupportedOperationException. Thecatch (Exception)block logs it only at DEBUG, so the property is silently not reset.Minimal bean (
ReadOnlyViewPropertiesin the reproducer). It has the same shape asInfinispanRemoteConfigurationProperties#setCluster(Map): bound through a write-only property, exposed through a read-only view.Steps to reproduce
readonly.label.a=1andreadonly.label.b=2into the Environment and callrebinder.rebind(beanName)→getLabels()is{a=1, b=2}readonly.label.bfrom the Environment and callrebinder.rebind(beanName)againmvn test -Dtest=RefreshUnmodifiableCollectionTests. DEBUG logging forConfigurationPropertiesRebinderis enabled in this test class.Expected
getLabels()is{a=1}.Actual
getLabels()is still{a=1, b=2}(removedEntryIsResetForUnmodifiableViewfails), and the exception only appears at DEBUG:The control test
removedEntryIsResetForMutableMappasses. It usesMutableViewProperties, which is identical except that the getter returns the mutable map.With Infinispan,
AttributeSet#attributes()returnsCollections.unmodifiableCollection(..). A single rebind ofInfinispanRemoteConfigurationPropertieslogsFailed to reset property 'attributes' on org.infinispan.commons.configuration.attributes.AttributeSet … UnsupportedOperationException1,955 times (once per recursion level of problem 1) before theStackOverflowError.Problem 3: properties with a setter that rejects
nullare silently not resetDescription
For a writable property,
resetPropertiescalls the setter with the value from a fresh default instance. When the default isnull, that meanssetXxx(null). Before #1680, setters only received values that were actually bound, so many don't acceptnull:value.strip(),URI.create(value),Duration.parse(value)Objects.requireNonNull(..)/Assert.notNull(..)java.util.Properties/ConcurrentHashMapThe exception is caught by the same
catch (Exception)block and logged only at DEBUG, so the property is silently not reset.Minimal bean (
NullRejectingSetterPropertiesin the reproducer):Steps to reproduce
nullrejecting.endpoint=https://old.example.cominto the Environment and callrebinder.rebind(beanName)→getEndpoint()is"https://old.example.com"nullrejecting.endpointfrom the Environment and callrebinder.rebind(beanName)againmvn test -Dtest=RefreshNullRejectingSetterTests. DEBUG logging forConfigurationPropertiesRebinderis enabled in this test class.Expected
getEndpoint()isnull.Actual
getEndpoint()is still"https://old.example.com"(removedPropertyIsResetForNullRejectingSetterfails), and the exception only appears at DEBUG:The control test
removedPropertyIsResetForPlainSetterpasses. It usesplainEndpointon the same bean, which has a plain setter and is reset tonullcorrectly.With Infinispan, the reset fails this way for
URI,SSLProtocol,authUsername,authPassword,authRealmandauthToken. Their setters write into ajava.util.Properties, e.g.setURI(uri)→props.setProperty(URI, uri)→ConcurrentHashMap.putVal→With Infinispan, the reset fails this way for
URI,SSLProtocol,authUsername,authPassword,authRealmandauthToken. Their setters write into ajava.util.Properties, e.g.setURI(uri)→props.setProperty(URI, uri)→ConcurrentHashMap.putVal→NullPointerException. For Infinispan this happens to cause no harm: the reset also clears the read-onlygetProperties()(the samePropertiesinstance), so the values are reset anyway. We checked this with the workaround for problem 1 in place.Root cause analysis
Problem 1.
resetPropertiesrecurses into every readable, non-writable, non-simple property. The only thing that stops the recursion is the identity-basedvisitedset added for #1698, so it only works when the object graph is made of stable instances. With Infinispan, the path is:InfinispanRemoteConfigurationProperties#getConfigurationBuilder()is read-only and returnsnew ConfigurationBuilder()on each call. Its type is inorg.infinispan..., soisResettableNestedType(..)returnstrue.ConfigurationBuilder→statistics→attributes(org.infinispan.commons.configuration.attributes.AttributeSet).AttributeSethas a fieldboolean protectand a methodAttributeSet protect()that returns a new protected copy on every call. Spring Framework'sCachedIntrospectionResultstreats a method with nogetprefix as a readable "record-style" property when a field with the same name exists.resetPropertiesdescends intoattributes.protect.protect.protect…. Each level is a new instance, sovisited.add(..)is alwaystrue→StackOverflowError.The subtree being walked is a throw-away object built by a getter, so resetting it has no effect on the bean anyway. #1734 doesn't help because
InfinispanRemoteConfigurationPropertieshas only a no-argument constructor, and #1740 only changes hownever-reset-nested-typesis applied.Problems 2 and 3. Both come from this part of
resetProperties:A failed reset is treated as harmless. It isn't: the bean keeps the value the refresh was supposed to remove.
Related issues
All three problems come from the reset-to-defaults logic introduced in #1680 (5.0.2):
StackOverflowErrorinresetPropertiescaused by cyclic graphs (SSLContext). The fix in Do not recursively try to reset configuration properties for library types #1699 (5.0.3) added the identity-basedvisitedset and skips JDK /jakarta.*/javax.*types. Its javadoc already notes that such types "may expose internal collections or maps that must not be cleared". Problem 1 is the case that fix doesn't cover: the graph isn't cyclic, it grows without end because every getter call returns a new object, and the types involved are library types (org.infinispan.*). Problem 2 is the same "must not be cleared" concern for non-JDK types and for the top-level bean itself.never-reset-nested-typesto writable properties too. Related, but it changes neither the recursion into read-only properties nor how failures are handled.resetBeanToDefaultstransiently nulls nested@ConfigurationPropertiesobjects, NPEing concurrent request threads #1727 (open):resetBeanToDefaultsbriefly leaves nested propertiesnullduring rebind, causing NPEs in concurrent readers. See also ConfigurationPropertiesRebinder exposes invalid state racily #1709 and Add a per-bean-name lock around destroy/reset/re-populate/re-initialize steps in rebind #1721.@Value-injected fields on@ConfigurationPropertiesbeans on the firstEnvironmentChangeEvent. #1716 / Autowire beans when rebinding #1720:@Value-injected fields permanently set tonullafter the reset. Like problem 3, this comes from the reset writingnulldefaults into places that were never meant to receivenull.ConfigServerHealthIndicator).Workaround
Problem 1:
spring.cloud.refresh.never-reset-nested-types=org.infinispan.spring.cloud.refresh.never-reset-nested-types=org.infinispan.The reproducer's
RefreshWorkaroundTestsshows that this works on 5.0.3 and later (the property doesn't exist in 5.0.2).spring.cloud.refresh.never-refreshablealso avoids the error, but then the bean is not rebound at all.Problems 2 and 3: no workaround, short of changing the bean. Make the getter return the mutable collection/map (problem 2), or make the setter accept
null(problem 3).Possible fixes
Problem 1:
value == target.getPropertyValue(name). A getter that returns a new object each time can't hold state that needs a reset.Problems 2 and 3:
UnsupportedOperationExceptionfromclear()explicitly. Apply the same stable-instance check as for problem 1, because an unmodifiable copy such asList.copyOf(..)orMap.of()can't hold state that needs a reset either.setXxx(defaultValue)when the current value already equals the default (ObjectUtils.nullSafeEquals). That avoids needless setter calls. It doesn't help when the value really changed, so the WARN is still needed there.General: given the number of regressions caused by #1680 (#1698, #1716, #1727, #1733, and this issue), consider making the reset-to-defaults behaviour opt-in.