spring-cloud / spring-cloud/spring-cloud-commons

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

Open
#1,727 1 comment 1 reaction 0 assignees View on GitHub

Nobody has claimed this yet.

waiting-for-triage
Dominant language
Java
Stars
751
Forks
744
Avg merge
1d 14h
Merged PRs (30d)
9

Description

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 (#1709 was a closed PR; #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 #1680 changed the behavior for non-proxied beans that #1662 had deliberately
    left alone for backwards compatibility.
References
  • #1680 introduced the reset (5.0.2); #1662 was the superseded variant that kept non-proxied beans safe.
  • #1709 reported the racy window with a repro; closed 2026-08-10, with the reporter noting on 2026-08-17
    that #1721 does not fix it.
  • #1721 added per-bean-name rebind locks (5.0.3) — serializes rebinders, not readers.
  • #1716 the permanent @Value nulling variant, fixed by #1720 in 5.0.3.
  • #750 open since 2020, "there never was a guarantee of thread safety refreshing @ConfigurationProperties".

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with resetBeanToDefaults/resetProperties and the rebind path through ConfigurationPropertiesBindingPostProcessor; run the attached reproduction with the listed spring-cloud-context versions. Review how ConfigurationPropertiesBeans and ContextRefresher participate in refresh, then define a safe behavior for nested properties during rebinding. Done means the deterministic and concurrent reader tests pass without NPEs while rebinding remains supported.

Written by the indexing model from the issue text.

Assessment

Tech stack
java, spring, spring-boot
Domain
backend, cloud
Issue type
Bug
Difficulty
5/5
Estimated time
Over a week
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
48/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.