Affects: 5.2.9


It seems that if a dependent thread-scoped bean is retrieved from the ApplicationContext before the thread-scoped dependency, then bean creation could lock up in an infinite loop, never to return again.

Regression bug. Works fine with spring-framework 5.2.7, fails with spring-framework 5.2.9. May be a bug in spring-test rather than spring-context. May be hard to reproduce, hoping that this limited information is sufficient to lead to a fix.

SimpleThreadScope likely relevant since SimpleThreadScope has an infinite loop - the bean creation freezes in:

// org.springframework.context.support.SimpleThreadScope.class, the scope map is empty
public Object get(String name, ObjectFactory<?> objectFactory) {
    
  Map<String, Object> scope = (Map)this.threadScope.get();
    
    return scope.computeIfAbsent(name, (k) -> {
        
  return objectFactory.getObject();
    
});


Standalone test case:

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
import org.springframework.beans.factory.config.Scope;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.support.GenericApplicationContext;
import org.springframework.context.support.SimpleThreadScope;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.TestContext;
import org.springframework.test.context.TestExecutionListeners;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.test.context.support.AbstractTestExecutionListener;
import org.springframework.test.context.support.AnnotationConfigContextLoader;
import org.springframework.test.context.support.DependencyInjectionTestExecutionListener;

@ExtendWith(SpringExtension.class)
@ContextConfiguration(classes={MyTest.MyTestConfig.class},
        loader= AnnotationConfigContextLoader.class)
@TestExecutionListeners({MyTest.MyTestExecutionListener.class,
        DependencyInjectionTestExecutionListener.class})
public class MyTest {
    @Autowired
    private MyView removeNodeStatusScreen;

    @Test
    void justRun() {
        System.out.println("This test should run");
    }

    @Configuration
    static class MyTestConfig {
        @Bean
        @org.springframework.context.annotation.Scope("thread")
        // The bean name seems to matter, if I change the name the test passes
        public MyView removeNodeStatusScreen(
                MyPresenter myPresenter) {
            return new MyView(myPresenter);
        }

        @Bean
        @org.springframework.context.annotation.Scope("thread")
        // The bean name seems to matter, if I change the name the test passes
        public MyPresenter removeNodeStatusPresenter() {
            return new MyPresenter();
        }
    }

    static class MyTestExecutionListener
            extends AbstractTestExecutionListener {

        @Override
        public void prepareTestInstance(TestContext testContext) throws Exception {
            if (testContext.getApplicationContext() instanceof GenericApplicationContext) {
                GenericApplicationContext context =
                        (GenericApplicationContext) testContext.getApplicationContext();
                ConfigurableListableBeanFactory beanFactory = context
                        .getBeanFactory();
                Scope viewScope = new SimpleThreadScope();
                beanFactory.registerScope("thread", viewScope);
            }
        }
    }

    public static class MyPresenter {
        public MyPresenter() {
        }
    }

    public static class MyView {
        public MyView(MyPresenter myPresenter) {
        }
    }
}

2 possible workarounds found:

Workaround 1: Add @DependsOn annotation to configuration class. However, @DependsOn would not be necessary if there were no bug:

@Configuration
public class MyConfig {
  @ViewScope
  @Bean 
  @DependsOn("myPresenter")
  public MyView myView(MyPresenter myPresenter) {
    return new MyView(myPresenter);
  }

  @Bean
  @ViewScope
  public MyPresenter myPresenter() {
     return new myPresenter();
  }
}

Workaround 2: swap order of beans in test. The order of the beans would not matter if there were no bug.

class MyConfigTest {
  // myView depends on myPresenter, so make sure the myPresenter is declared first
  // Otherwise test freezes on bean creation
  @Autowired
  MyPresenter myPresenter;

  @Autowired
  MyView myView;
...

Comment From: sbrannen

Potentially related to #25618.

@nguyenquoc, out of curiosity, what version of the JDK are you using?

Comment From: sbrannen

Works fine with spring-framework 5.2.7, fails with spring-framework 5.2.9. May be a bug in spring-test rather than spring-framework. May be hard to reproduce, hoping that this limited information is sufficient to lead to a fix.

I tried to reproduce this with the following all-in-one test class.

@SpringJUnitConfig
@TestExecutionListeners(listeners = MyConfigTests.MyConfigTestExecutionListener.class, mergeMode = MergeMode.MERGE_WITH_DEFAULTS)
class MyConfigTests {

    @Autowired
    MyView myView;

    @Autowired
    MyPresenter myPresenter;

    @Test
    void testSomething() {
        // empty test, never runs because Bean creation freezes
    }

    static class MyConfigTestExecutionListener extends AbstractTestExecutionListener {

        @Override
        public int getOrder() {
            return Ordered.HIGHEST_PRECEDENCE;
        }

        @Override
        public void prepareTestInstance(TestContext testContext) throws Exception {
            if (testContext.getApplicationContext() instanceof GenericApplicationContext) {
                GenericApplicationContext context = (GenericApplicationContext) testContext.getApplicationContext();
                ConfigurableListableBeanFactory beanFactory = context.getBeanFactory();
                beanFactory.registerScope("thread", new SimpleThreadScope());
            }
        }
    }

    @Configuration
    static class MyConfig {

        @Bean
        @Scope("thread")
        MyView myView(MyPresenter myPresenter) {
            return new MyView(myPresenter);
        }

        @Bean
        @Scope("thread")
        MyPresenter myPresenter() {
            return new MyPresenter();
        }

    }

    static class MyView {

        MyView(MyPresenter myPresenter) {
        }
    }

    static class MyPresenter {
    }

}

This passes against 5.2.7, 5.2.8, 5.2.9, and master using Java 8. It fails on 5.2.7 and 5.2.8 using Java 11+, but that failure is different and was fixed in #25618.

@nguyenquoc, can you please run the above test class in your environment and let me know if it passes for you?

Comment From: nguyenquoc

Using JDK:

openjdk version "1.8.0_265" OpenJDK Runtime Environment (Zulu 8.48.0.53-CA-macosx) (build 1.8.0_265-b11) OpenJDK 64-Bit Server VM (Zulu 8.48.0.53-CA-macosx) (build 25.265-b11, mixed mode)

Reproduced on Windows and Linux also.

Comment From: sbrannen

Using JDK:

openjdk version "1.8.0_265" OpenJDK Runtime Environment (Zulu 8.48.0.53-CA-macosx) (build 1.8.0_265-b11) OpenJDK 64-Bit Server VM (Zulu 8.48.0.53-CA-macosx) (build 25.265-b11, mixed mode)

Thanks for the feedback!

Reproduced on Windows and Linux also.

Were you able to reproduce it using the all-in-one test case I posted in https://github.com/spring-projects/spring-framework/issues/25801#issuecomment-697253945?

Comment From: nguyenquoc

The all-in-one test case runs fine in my environment. Apologies, I haven't had a chance to create.a minimal test case to demonstrate the bug yet partially because I have a workaround.

Comment From: sbrannen

The all-in-one test case runs fine in my environment.

Thanks for confirming.

Apologies, I haven't had a chance to create.a minimal test case to demonstrate the bug yet partially because I have a workaround.

No problem.

It appears that the bug may only arise in more complex scenarios which may be difficult to reproduce in a reliable test case.

If you find a way to reliably reproduce the bug in the coming week, that would be great!

If not, we might just roll back the changes introduced in #25038 and #25618, since this would effectively be the second reported regression for these code paths.

Comment From: nguyenquoc

I updated the original post with a standalone test case, thanks for providing the all-in-one sample.

Amazingly, the name of the bean in the configuration class seems to matter.

Comment From: sbrannen

I updated the original post with a standalone test case, thanks for providing the all-in-one sample.

Thanks. And... you're welcome.

Amazingly, the name of the bean in the configuration class seems to matter.

OK. That seems truly bizarre. We'll investigate!

Comment From: sbrannen

I figured out what's going on here.

Basically, the two bean names removeNodeStatusScreen and removeNodeStatusPresenter end up in the same bucket within ConcurrentHashMap, and attempting to store the respective objects in the same bucket recursively via computeIfAbsent() will result in the process hanging on Java 8.

This bug was fixed in Java 9 (see https://bugs.openjdk.java.net/browse/JDK-8062841). When I execute the same test on Java 9 or higher, the test fails with the following root cause.

Caused by: java.lang.IllegalStateException: Recursive update
    at java.util.concurrent.ConcurrentHashMap.computeIfAbsent(ConcurrentHashMap.java:1774) ~[?:?]
    at org.springframework.context.support.SimpleThreadScope.get(SimpleThreadScope.java:70) ~[main/:?]

Note that changing the initial capacity of the ConcurrentHashMap in SimpleThreadScope to 12 or more allows the test to pass as well, since there is then no longer an attempt to enter the same bucket recursively.

Changing the names of the two beans has a similar effect, but we cannot rely on user-defined bean names to magically work, and we cannot pick a magic initial capacity that seems to work.

In light of the above findings, we do in fact consider this a regression, and we will revert the corresponding changes introduced in #25038 and #25618.


p.s. for those interested...

With the default initial capacity for ConcurrentHashMap, the strings removeNodeStatusScreen and removeNodeStatusPresenter both end up in bucket 15 in the ConcurrentHashMap.table node array.

With an initial capacity of 12 for ConcurrentHashMap, the strings removeNodeStatusScreen and removeNodeStatusPresenter end up in buckets 15 and 31, respectively, in the ConcurrentHashMap.table node array.

Comment From: nguyenquoc

Thanks very much @sbrannen, appreciate the thorough investigation!