Skip to content

ConfigurationPropertiesRebinder.resetProperties: StackOverflowError and silently skipped resets (e.g. Infinispan) #1750

Description

@michael-wirth

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:

  1. Download and unzip spring-cloud-refresh-stackoverflow-repro.zip
  2. cd spring-cloud-refresh-stackoverflow-repro
  3. 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

  1. Register InfinispanRemoteConfigurationProperties or FluentProperties via @EnableConfigurationProperties
  2. Call ContextRefresher.refresh() (the same code path as POST /actuator/refresh) or ConfigurationPropertiesRebinder.rebind(beanName)
  3. 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

  1. Put readonly.label.a=1 and readonly.label.b=2 into the Environment and call rebinder.rebind(beanName) → getLabels() is {a=1, b=2}
  2. Remove readonly.label.b from the Environment and call rebinder.rebind(beanName) again
  3. 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

  1. Put nullrejecting.endpoint=https://old.example.com into the Environment and call rebinder.rebind(beanName) → getEndpoint() is "https://old.example.com"
  2. Remove nullrejecting.endpoint from the Environment and call rebinder.rebind(beanName) again
  3. 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:

  1. InfinispanRemoteConfigurationProperties#getConfigurationBuilder() is read-only and returns new ConfigurationBuilder() on each call. Its type is in org.infinispan..., so isResettableNestedType(..) returns true.
  2. ConfigurationBuilder → statistics → attributes (org.infinispan.commons.configuration.attributes.AttributeSet).
  3. 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.
  4. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions