Skip to content

resetBeanToDefaults transiently nulls nested @ConfigurationProperties objects, NPEing concurrent request threads #1727

Description

@rsheldon-dealeron

Summary

Since spring-cloud-context 5.0.2 (resetBeanToDefaults, #1680), a rebind empties the live
@ConfigurationProperties singleton before repopulating it. For a bean with nested property
objects, the observable intermediate state is not merely a stale or new scalar — it is null, so a
concurrent reader does not read a wrong value, it throws NullPointerException.

This is the same window PR #1709 reported and #1721 partially addressed (writer/writer only). #1709
was closed with the observation acknowledged; this report adds the failure mode it did not cover — a
hard failure rather than a wrong value — and evidence of how often it is hit in a framework where
every service has nested properties read on request threads.

Environment

  • spring-cloud-context 5.0.2 and 5.0.3 (trains 2025.1.2, 2025.1.3); Spring Boot 4.0.8. Not present in
    4.3.x/4.2.x.

What happens

@Bean
@ConfigurationProperties(prefix = "my.service")
MyProperties myProperties() { return new MyProperties(); }

public class MyProperties {
    private Timeout timeout = null;          // populated only by binding
    public Timeout getTimeout() { return timeout; }
    // ...
}

resetProperties walks the BeanWrapper descriptors and, for every writable property, does
target.setPropertyValue(name, defaultsWrapper.getPropertyValue(name)). For timeout that is
setTimeout(null). Between resetBeanToDefaults(bean) and the binding inside
initializeBean(bean, name), every reader of myProperties.getTimeout().getX() throws NPE.

spring.cloud.refresh.never-reset-nested-types does not help: it controls recursion into a non-null
nested value, not the top level setTimeout(null).

Reproduction

rebind-reset-repro.zip is a self-contained project — Spring Boot 4.0.8 with
spring-cloud-context as the only other dependency, no Spring Cloud Config, no Consul, no
@RefreshScope. The test is also attached on its own as RebindEmptiesNestedPropertiesTest.java.txt,
and the three runs below as test-results.txt.

mvn test                                        # 5.0.3 (default) - both tests fail
mvn test -Dspring-cloud-context.version=5.0.2   # both tests fail
mvn test -Dspring-cloud-context.version=5.0.1   # both tests pass
spring-cloud-context nestedPropertyIsReadableThroughoutARebind concurrentReadersSurviveRepeatedRebinds
5.0.1 passes passes, 0 failed reads
5.0.2 fails, NPE fails, 10,942,734 of 19,659,911 reads
5.0.3 fails, NPE fails, 11,072,765 of 20,753,865 reads

The first test is deterministic rather than a race: a PriorityOrdered BeanPostProcessor reads the bean
from inside the re-bind, ahead of ConfigurationPropertiesBindingPostProcessor, which is where a request
thread lands.

NullPointerException: Cannot invoke "...MyProperties$Timeout.getConnect()"
    because the return value of "...MyProperties.getTimeout()" is null

The second is one reader thread against 200 re-binds. A tight reader loop overstates the proportion
compared with real traffic, but it shows the window is wide rather than a few instructions.

Impact and frequency

Measured in a framework used across a fleet of services, where a Consul config watch fires
ContextRefresher.refresh() on a running service:

  • In a framework integration test, 5,363 of 2,647,116 reads failed with NPE during a single
    ContextRefresher.refresh()
    - 4 reader threads resolving an HTTP connect timeout through the
    properties bean, exactly as request threads do for every outbound call.
  • In production this is a burst of failed outbound calls or 5xx per config change, not a sustained
    outage — but it is one burst per config change, per service, unattended.
  • It is aggravated by ContextRefresher.refresh() also calling scope.refreshAll(): @RefreshScope
    beans that read the properties bean are rebuilt at exactly the moment the properties they read are
    reset. An application with an async ApplicationEventMulticaster makes it near deterministic,
    because /refresh returns before the rebind has finished.

Why @RefreshScope is only a partial answer

@RefreshScope on the properties bean does work — ConfigurationPropertiesBeans skips refresh scoped
beans, so they are replaced rather than rebound — and that is the mitigation adopted here. But it has
to be applied bean by bean, by whoever owns each bean. A framework can scope its own properties beans;
it cannot scope the ones its consuming applications declare. Every application that upgrades to a
2025.1.2+ train silently acquires this exposure in its own configuration classes, with no warning at
build or start time, and the symptom surfaces as an unexplained NPE burst minutes after a config change.

Request

  1. An open issue tracking the reader visible window (ConfigurationPropertiesRebinder exposes invalid state racily #1709 was a closed PR; Thread safety of @ConfigurationProperties beans when refreshing #750 predates the reset and
    is about a different symptom), so the state of it is discoverable.
  2. Consider making the reset opt-in — or at least opt-out per bean without losing rebinding — on 5.0.x,
    rather than only in the next major. The reset restores removed properties to defaults, which is
    valuable, but it turns a previously non-failing operation into a failing one for any bean with
    nested properties, and Reset bean to defaults before rebinding values #1680 changed the behavior for non-proxied beans that Recreate proxied @ConfigurationProperties beans on rebind #1662 had deliberately
    left alone for backwards compatibility.

References

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions