Skip to content

Commit 4ad38de

Browse files
committed
Fix concurrent initialization race in FeignHttpMessageConverters
initConvertersIfRequired() assigned the converters field an empty ArrayList before populating it, and the field was neither volatile nor guarded. A concurrent caller could therefore observe the non-null but empty (or partially populated) list and fail with "no suitable HttpMessageConverter found for request type". Build the list in a local variable under double-checked locking and publish it to a volatile field only once fully initialized. Fixes gh-1371 Signed-off-by: seonwoo_jung <79202163+seonwooj0810@users.noreply.github.com>
1 parent d9f528b commit 4ad38de

2 files changed

Lines changed: 129 additions & 10 deletions

File tree

spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/support/FeignHttpMessageConverters.java

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ public class FeignHttpMessageConverters {
3636

3737
private final ObjectProvider<HttpMessageConverterCustomizer> cloudCustomizers;
3838

39-
private List<HttpMessageConverter<?>> converters;
39+
private volatile List<HttpMessageConverter<?>> converters;
4040

4141
public FeignHttpMessageConverters(ObjectProvider<ClientHttpMessageConvertersCustomizer> customizers,
4242
ObjectProvider<HttpMessageConverterCustomizer> cloudCustomizers) {
@@ -51,16 +51,23 @@ public List<HttpMessageConverter<?>> getConverters() {
5151

5252
private void initConvertersIfRequired() {
5353
if (this.converters == null) {
54-
this.converters = new ArrayList<>();
55-
HttpMessageConverters.ClientBuilder builder = HttpMessageConverters.forClient();
56-
// TODO: allow disabling of registerDefaults
57-
builder.registerDefaults();
58-
// TODO: check if already added? Howto order?
54+
synchronized (this) {
55+
if (this.converters == null) {
56+
List<HttpMessageConverter<?>> converters = new ArrayList<>();
57+
HttpMessageConverters.ClientBuilder builder = HttpMessageConverters.forClient();
58+
// TODO: allow disabling of registerDefaults
59+
builder.registerDefaults();
60+
// TODO: check if already added? Howto order?
5961

60-
this.customizers.orderedStream().forEach(customizer -> customizer.customize(builder));
61-
HttpMessageConverters hmc = builder.build();
62-
hmc.forEach(converter -> converters.add(converter));
63-
cloudCustomizers.forEach(customizer -> customizer.accept(this.converters));
62+
this.customizers.orderedStream().forEach(customizer -> customizer.customize(builder));
63+
HttpMessageConverters hmc = builder.build();
64+
hmc.forEach(converter -> converters.add(converter));
65+
cloudCustomizers.forEach(customizer -> customizer.accept(converters));
66+
// Publish only once fully built so concurrent callers never
67+
// observe a partially populated list.
68+
this.converters = converters;
69+
}
70+
}
6471
}
6572
}
6673

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
/*
2+
* Copyright 2013-present the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.cloud.openfeign.support;
18+
19+
import java.util.Collections;
20+
import java.util.List;
21+
import java.util.concurrent.CountDownLatch;
22+
import java.util.concurrent.TimeUnit;
23+
import java.util.concurrent.atomic.AtomicBoolean;
24+
import java.util.concurrent.atomic.AtomicReference;
25+
import java.util.stream.Stream;
26+
27+
import org.junit.jupiter.api.Test;
28+
29+
import org.springframework.beans.factory.ObjectProvider;
30+
import org.springframework.boot.http.converter.autoconfigure.ClientHttpMessageConvertersCustomizer;
31+
import org.springframework.http.converter.HttpMessageConverter;
32+
33+
import static org.assertj.core.api.Assertions.assertThat;
34+
import static org.mockito.Mockito.mock;
35+
import static org.mockito.Mockito.when;
36+
37+
/**
38+
* Tests for {@link FeignHttpMessageConverters}.
39+
*
40+
* @author seonwoo_jung
41+
*/
42+
class FeignHttpMessageConvertersTests {
43+
44+
@Test
45+
void shouldNotExposePartiallyInitializedConvertersToConcurrentCallers() throws Exception {
46+
CountDownLatch initStarted = new CountDownLatch(1);
47+
CountDownLatch allowInitToFinish = new CountDownLatch(1);
48+
49+
// A customizer that blocks while the converter list is being built so that we can
50+
// observe what a second, concurrent caller sees mid-initialization.
51+
ClientHttpMessageConvertersCustomizer blockingCustomizer = builder -> {
52+
initStarted.countDown();
53+
try {
54+
allowInitToFinish.await();
55+
}
56+
catch (InterruptedException ex) {
57+
Thread.currentThread().interrupt();
58+
}
59+
};
60+
61+
@SuppressWarnings("unchecked")
62+
ObjectProvider<ClientHttpMessageConvertersCustomizer> customizers = mock(ObjectProvider.class);
63+
when(customizers.orderedStream()).thenReturn(Stream.of(blockingCustomizer));
64+
@SuppressWarnings("unchecked")
65+
ObjectProvider<HttpMessageConverterCustomizer> cloudCustomizers = mock(ObjectProvider.class);
66+
when(cloudCustomizers.iterator()).thenReturn(Collections.emptyIterator());
67+
68+
FeignHttpMessageConverters feignConverters = new FeignHttpMessageConverters(customizers, cloudCustomizers);
69+
70+
AtomicReference<List<HttpMessageConverter<?>>> initializerResult = new AtomicReference<>();
71+
AtomicReference<List<HttpMessageConverter<?>>> readerResult = new AtomicReference<>();
72+
AtomicBoolean readerObservedEmptyList = new AtomicBoolean();
73+
74+
Thread initializer = new Thread(() -> initializerResult.set(feignConverters.getConverters()),
75+
"converters-init");
76+
Thread reader = new Thread(() -> {
77+
List<HttpMessageConverter<?>> converters = feignConverters.getConverters();
78+
readerObservedEmptyList.set(converters.isEmpty());
79+
readerResult.set(converters);
80+
}, "converters-reader");
81+
82+
initializer.start();
83+
assertThat(initStarted.await(5, TimeUnit.SECONDS)).isTrue();
84+
85+
// The first caller is now stuck building the list. Start a concurrent caller and
86+
// wait until it has either blocked waiting for initialization to complete (fixed
87+
// behaviour) or already returned a value (buggy behaviour) before releasing.
88+
reader.start();
89+
waitUntilBlockedOrFinished(reader);
90+
allowInitToFinish.countDown();
91+
92+
initializer.join(TimeUnit.SECONDS.toMillis(5));
93+
reader.join(TimeUnit.SECONDS.toMillis(5));
94+
95+
assertThat(initializerResult.get()).isNotEmpty();
96+
assertThat(readerObservedEmptyList).as("concurrent caller observed an empty converter list").isFalse();
97+
assertThat(readerResult.get()).isSameAs(initializerResult.get());
98+
}
99+
100+
private static void waitUntilBlockedOrFinished(Thread thread) {
101+
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
102+
while (System.nanoTime() < deadline) {
103+
Thread.State state = thread.getState();
104+
if (state == Thread.State.BLOCKED || state == Thread.State.WAITING || state == Thread.State.TIMED_WAITING
105+
|| state == Thread.State.TERMINATED) {
106+
return;
107+
}
108+
Thread.yield();
109+
}
110+
}
111+
112+
}

0 commit comments

Comments
 (0)