Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Thread-scoped bean creation freezes if dependent bean is retrieved before dependency bean #25801

Closed
nguyenquoc opened this issue Sep 23, 2020 · 10 comments
Assignees
Labels
in: core Issues in core modules (aop, beans, core, context, expression) type: regression A bug that is also a regression
Milestone

Comments

@nguyenquoc
Copy link

nguyenquoc commented Sep 23, 2020

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;
...
@spring-projects-issues spring-projects-issues added the status: waiting-for-triage An issue we've not yet triaged or decided on label Sep 23, 2020
@sbrannen sbrannen added the in: core Issues in core modules (aop, beans, core, context, expression) label Sep 23, 2020
@sbrannen
Copy link
Member

Potentially related to #25618.

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

@sbrannen
Copy link
Member

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?

@sbrannen sbrannen added the status: waiting-for-feedback We need additional information before we can continue label Sep 23, 2020
@jhoeller jhoeller added the type: regression A bug that is also a regression label Sep 23, 2020
@jhoeller jhoeller added this to the 5.2.10 milestone Sep 23, 2020
@jhoeller jhoeller removed the status: waiting-for-triage An issue we've not yet triaged or decided on label Sep 23, 2020
@nguyenquoc
Copy link
Author

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.

@spring-projects-issues spring-projects-issues added status: feedback-provided Feedback has been provided and removed status: waiting-for-feedback We need additional information before we can continue labels Sep 23, 2020
@sbrannen
Copy link
Member

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 #25801 (comment)?

@sbrannen sbrannen removed the status: feedback-provided Feedback has been provided label Sep 23, 2020
@nguyenquoc
Copy link
Author

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.

@sbrannen
Copy link
Member

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.

@nguyenquoc
Copy link
Author

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.

@sbrannen sbrannen changed the title Bean creation freezes if dependent bean is declared before dependency bean Thread-scoped bean creation freezes if dependent bean is retrieved before dependency bean Sep 24, 2020
@sbrannen
Copy link
Member

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!

@sbrannen
Copy link
Member

sbrannen commented Sep 24, 2020

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.

@nguyenquoc
Copy link
Author

Thanks very much @sbrannen, appreciate the thorough investigation!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
in: core Issues in core modules (aop, beans, core, context, expression) type: regression A bug that is also a regression
Projects
None yet
Development

No branches or pull requests

4 participants