Skip to content

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

Closed

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

Activity

  1. 180254 commented on Aug 23, 2026

    @180254

    We hit similar (the same?) regression.

    What is happening on our production code: A request thread in our service reads an int parallelExecutions from a @ConfigurationProperties bean (annotated @Positive, configured to 4) to decide how many worker flows to start. During a rebind it read 0, the unbound Java default, a value that is not configured anywhere and that the bean's own validation forbids. The thread started zero workers and then waited forever for them to complete. The pipeline froze permanently.

    Two additions from our case:

    • The window opens even when nothing changes. ContextRefresher.refreshEnvironment() publishes EnvironmentChangeEvent with an empty key set and all beans are still rebound, so an application that refreshes on a schedule or on config-store notifications re-runs the reset on every cycle with zero config changes.
    • I built a minimal reproducer (one property set to value 4, one @ConfigurationProperties bean with a single int, one thread looping refreshEnvironment() without changing anything, one reader thread): 5.0.1 stays clean; 5.0.2, 5.0.3 and 5.0.4-SNAPSHOT (build 5.0.4-20260823.000329-5) all show roughly 1 in 40 reads observing the unbound 0. Reproducer: https://github.com/180254/spring-cloud-commons-issues-1727
  2. sharanggupta commented on Oct 22, 2026

    @sharanggupta

    @ryanjbaxter, I looked at the reader/writer window that remains after #1721. The null/0 intermediate state comes from the order of operations: the live bean is reset to its class-level defaults first and only re-bound afterwards, so between the two steps every reader sees unbound state.

    Binding a fresh instance first and then copying its values into the live bean removes that window without touching the read path: readers can still see a mix of old and new values during the copy (as before #1680), but never null for a nested object or 0 for a bound scalar. Removed properties still revert to their defaults, because the fresh instance only carries what the Environment binds, and since the binding happens before the live bean is destroyed, a rebind whose binding fails (for example an unconvertible value) now leaves the live bean exactly as it was. The fresh instance is bound through ConfigurationPropertiesBindingPostProcessor.postProcessBeforeInitialization directly, so no other post-processor or lifecycle callback runs for it.

    Branch with a failing test (ConfigurationPropertiesRebinderTransientStateIntegrationTests: on main a nested object populated only by binding is set to null, and an int passes through 0) and the change: main...sharanggupta:spring-cloud-commons:gh-1727-rebind-fresh-instance

    The spring-cloud-context suite passes. Happy to open a PR if this direction fits; the Javadoc note about @RefreshScope for a consistent view would still apply to mixed old/new reads.

  3. ryanjbaxter commented on Oct 22, 2026

    @ryanjbaxter
    Contributor

    This seems directionally correct. A PR would be great! Just a couple of notes

    1. The PR needs to targt the 5.0.x branch
    2. Getter-only Collection and Map properties still have a window. The copy does collection.clear() and then addAll(fresh), so a reader can see an empty list or map. It can also hit a ConcurrentModificationException while iterating. This is the same class of bug as resetBeanToDefaults transiently nulls nested @ConfigurationProperties objects, NPEing concurrent request threads #1727 for a different property shape.
    3. The no-arg constructor and @Autowired injection run an extra time per rebind. The old code already constructed a fresh instance, but it now also autowires it. If ConfigurationPropertiesBindingPostProcessor isn't available, bind is a silent no-op. The fresh instance then holds only defaults, and the copy would write those defaults onto the live bean.
  4. sharanggupta commented on Oct 22, 2026

    @sharanggupta

    Thanks, @ryanjbaxter. PR #1774 is against 5.0.x and takes the three notes into account:

    1. getter-only collections and maps are no longer cleared and refilled: equal contents are left alone, a list gets the new elements before the previous ones are removed, and a set or map only drops the removed elements or keys before the fresh ones go in, so a reader never sees an empty one. The binder's own CollectionBinder.merge still clears and refills a getter-only collection it binds, as it did before; that window is Spring Boot's rather than the rebinder's.
    2. when no ConfigurationPropertiesBindingPostProcessor is available the reset is skipped (debug log) rather than copying defaults onto the live bean.
    3. the fresh instance is autowired deliberately, so that an injected dependency is copied as the same instance instead of being nulled by the copy and re-injected afterwards; the previous code already instantiated a fresh instance per rebind, so the autowiring is the only extra cost. Happy to drop it if you would rather not have it.
  5. added a commit that references this issue on Oct 28, 2026
  6. ryanjbaxter commented on Oct 30, 2026

    @ryanjbaxter
    Contributor

    @180254 and @rsheldon-dealeron would you mind testing the changes in #1781 and see if they address your issue?

  7. added this to the 5.0.5 milestone on Oct 30, 2026
  8. 180254 commented on Oct 31, 2026

    @180254

    I built this PR and ran the 180254/spring-cloud-commons-issues-1727 reproducer against it. On 5.0.3 it fails within seconds; with this PR it ran clean for 60 seconds: 48k refresh cycles, 15B reads, 0 bad reads. So the scalar case is fixed.

    The reproducer only checks an int. I picked it because it was the clearest case, and it's the one that froze our app in production. If I read the known limitation in the PR description correctly, collection properties can still be observed empty during a rebind. I must admit that I don’t understand what is supposed to be working here, or why.

    I wasn't sure whether the way we used the periodic rebind was correct in the first place, so I looked at alternatives:

    1. Mark the configuration beans that are read at runtime @RefreshScope and call RefreshScope.refreshAll() when keys change, so readers get a new instance instead of one being reset in place.
    2. Drop the periodic reload. The only value we ever changed at runtime was a toggle that pauses message consumers, so a small file poller now watches just that toggle. Any other change goes through a pod restart, which our deployment already does on every config change.

    I went with the second one, and since then I haven't followed the topic closely.

    Thank you for working on this issue.

  9. ryanjbaxter commented on Nov 2, 2026

    @ryanjbaxter
    Contributor

    @180254 thanks for trying out the fix and confirming it works.

    If I read the known limitation in the PR description correctly, collection properties can still be observed empty during a rebind.

    Yes that is true, and the only way to work around this is to reimplement the bean initialization from Spring Boot and that is not something I want to do in Spring Cloud.

    Given the difficulties we have had since adding the restore defaults functionality when refreshing beans I am going to be looking into going back to our previous functionallity and make this new functionality opt in for those that want it.

  10. added 2 commits that reference this issue on Nov 2, 2026
    4cf420f
    74bfb9c
  11. moved this from In Progress to Done in 2025.1.5on Nov 2, 2026
  12. moved this from In Progress to Done in 2026.0.0-RC2on Nov 2, 2026
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