`ConcurrentModificationException` in `SchemaPropertyDeprecatingConverter.isDeprecated`, and `DEPRECATED_ANNOTATIONS` gains a duplicate on every context start
メンテナーはふだん 9 日以内に返信
評価
- 難易度
- 2/5
- 見積もり時間
- 1〜3時間
- 初心者へのやさしさ
- 85/100
- issue の種類
- バグ
- 明瞭さ
- 明確に書かれている
- 活発さ
- 活発
調査の方向性
SchemaPropertyDeprecatingConverter.isDeprecated と springdoc-openapi-starter-common 内の DEPRECATED_ANNOTATIONS 宣言から始め、これらのコレクションの動作を、issue で言及されている copy-on-write コレクションと比較してください。issue に記載された再現方法を使って、並行登録と繰り返し登録を確認してください。並行呼び出しで例外が発生せず、繰り返し登録後に期待される2つのアノテーション型だけが残れば完了です。
索引モデルが issue の本文から書いたものです。
説明
Describe the bug
SchemaPropertyDeprecatingConverter keeps the deprecated annotation types in a JVM-wide static Collections.synchronizedList. Two problems in springdoc-openapi-starter-common:
isDeprecated(Method)callsDEPRECATED_ANNOTATIONS.stream()without holding the list's lock. A synchronized list only synchronizes its own methods, and the Javadoc ofCollections.synchronizedListrequires a manual lock for traversal by iterator, spliterator or stream. A concurrentaddmakes the stream throw aConcurrentModificationException.SpringDocKotlinConfiguration'sinitblock callsSpringDocUtils.getConfig().addDeprecatedType(Deprecated::class.java)on every application context start. The list never deduplicates, so it grows by one entry per context. EveryisDeprecatedcall (once per handler method per/v3/api-docsgeneration) then iterates the duplicates.
Together they make /v3/api-docs fail with a 500 whenever one context in the JVM starts while another generates its docs:
java.util.ConcurrentModificationException
at java.base/java.util.ArrayList$ArrayListSpliterator.tryAdvance(ArrayList.java:1703)
at java.base/java.util.stream.ReferencePipeline.forEachWithCancel(ReferencePipeline.java:147)
...
at java.base/java.util.stream.ReferencePipeline.anyMatch(ReferencePipeline.java:668)
at org.springdoc.core.converters.SchemaPropertyDeprecatingConverter.isDeprecated(SchemaPropertyDeprecatingConverter.java:87)
at org.springdoc.api.AbstractOpenApiResource...
We hit this in a Kotlin Spring Boot application's integration tests: JUnit 5 runs test classes in parallel (junit.jupiter.execution.parallel.mode.classes.default=concurrent), test classes with different configurations start their own contexts, and a test that requests /v3/api-docs fails intermittently. A single production context is not affected, because its one add happens before the server serves requests.
The other static lists of this kind were already fixed for the same reason (#1641): HIDDEN_REST_CONTROLLERS, ADDITIONAL_REST_CONTROLLERS and ANNOTATIONS_TO_IGNORE copy a snapshot under the lock, and the ConverterUtils, AbstractRequestService and PolymorphicModelConverter collections are CopyOnWriteArrayList/CopyOnWriteArraySet. DEPRECATED_ANNOTATIONS is the remaining synchronizedList that is streamed without the lock.
To Reproduce
- spring-boot: not needed for the reproduction; we see it with 4.1.1
- springdoc-openapi:
springdoc-openapi-starter-common3.2.0 (we first saw it on 3.0.3)
build.gradle.kts:
plugins { application }
repositories { mavenCentral() }
dependencies {
implementation("org.springdoc:springdoc-openapi-starter-common:3.2.0")
implementation("org.jetbrains.kotlin:kotlin-stdlib:2.2.20")
implementation("org.springframework:spring-web:7.0.0")
}
application { mainClass = "Repro" }
src/main/java/Repro.java:
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.Collection;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import org.springdoc.core.configuration.SpringDocKotlinConfiguration;
import org.springdoc.core.converters.SchemaPropertyDeprecatingConverter;
public class Repro {
@Deprecated
public void endpoint() {
}
public static void main(String[] args) throws Exception {
// 1. Every Spring context start constructs SpringDocKotlinConfiguration, whose init block
// calls addDeprecatedType(kotlin.Deprecated) again: the static list gains a duplicate each time.
for (int i = 0; i < 100; i++) {
new SpringDocKotlinConfiguration();
}
System.out.println("DEPRECATED_ANNOTATIONS after 100 context starts: " + deprecatedAnnotations().size());
// 2. One context starting (writer) while another serves /v3/api-docs (reader, which calls isDeprecated
// for every handler method). isDeprecated streams the synchronizedList without holding its lock.
Method method = Repro.class.getMethod("endpoint");
ExecutorService pool = Executors.newFixedThreadPool(2);
Future<?> writer = pool.submit(() -> {
for (int i = 0; i < 20_000; i++) {
new SpringDocKotlinConfiguration();
}
});
Future<?> reader = pool.submit(() -> {
while (!writer.isDone()) {
SchemaPropertyDeprecatingConverter.isDeprecated(method);
}
});
try {
reader.get();
System.out.println("No exception this run");
}
finally {
pool.shutdownNow();
}
}
private static Collection<?> deprecatedAnnotations() throws ReflectiveOperationException {
Field field = SchemaPropertyDeprecatingConverter.class.getDeclaredField("DEPRECATED_ANNOTATIONS");
field.setAccessible(true);
return (Collection<?>) field.get(null);
}
}
gradle run on 3.2.0 (JDK 26), 5 out of 5 runs:
DEPRECATED_ANNOTATIONS after 100 context starts: 101
Exception in thread "main" java.util.concurrent.ExecutionException: java.util.ConcurrentModificationException
...
Caused by: java.util.ConcurrentModificationException
at java.base/java.util.ArrayList$ArrayListSpliterator.tryAdvance(ArrayList.java:1703)
...
at org.springdoc.core.converters.SchemaPropertyDeprecatingConverter.isDeprecated(SchemaPropertyDeprecatingConverter.java:87)
new SpringDocKotlinConfiguration() stands in for a context start: it runs the same init block. The reflection only prints the size.
Expected behavior
isDeprecateddoes not throw while another context registers deprecated types.- Registering a type that is already registered does not grow the list: 100 context starts leave two entries (
java.lang.Deprecatedandkotlin.Deprecated).
Suggested fix
Make DEPRECATED_ANNOTATIONS a CopyOnWriteArraySet, like PolymorphicModelConverter.PARENT_TYPES_TO_IGNORE. That fixes both: iteration works on a snapshot, and add ignores a duplicate. The list is written a few times at startup and read on every docs generation, which suits copy-on-write. Every use (add, contains, stream) is also on Set, so nothing else changes. With that change the reproduction prints 2 and passes 5 out of 5 runs.
- 主要言語
- Java
- スター
- 3.7k
- フォーク
- 605
- 平均マージ
- 9日 11時間
- マージ済み PR(30日)
- 14
環境構築
このプロジェクトには開発コンテナ、Dockerfile、コントリビューションガイドがありません。まず README を読み、一般的な手順ははじめてのコントリビューションガイドを参照してください。
はじめの一歩
- issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
- 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
- リポジトリをフォークし、ブランチを切って変更します。
- issue 番号を参照したプルリクエストを送ります。
springdoc/springdoc-openapi のほかの issue
-
enhancement upstream
難易度 4/5 3〜5日 初心者へのやさしさ 48/100
springdoc/springdoc-openapi#3324 · コメント 2 件 ·
メンテナーはふだん 9 日以内に返信
-
enhancement upstream
難易度 3/5 1〜2日 初心者へのやさしさ 55/100
springdoc/springdoc-openapi#3309 · コメント 4 件 · リアクション 1 件 ·
メンテナーはふだん 9 日以内に返信
-
難易度 3/5 1〜2日 初心者へのやさしさ 45/100
springdoc/springdoc-openapi#3404 · コメント 1 件 ·
メンテナーはふだん 9 日以内に返信
springdoc/springdoc-openapi の issue をすべて見る
似ている issue
-
難易度 2/5 1〜3時間 初心者へのやさしさ 77/100
NationalSecurityAgency/ghidra#10149 ·
メンテナーはふだん 2 日以内に返信
-
難易度 2/5 1〜3時間 初心者へのやさしさ 90/100
yegor256/antlr2ebnf-maven-plugin#155 ·
メンテナーはふだん 1 日以内に返信
-
account bug exercise lecture programming user interface
難易度 2/5 1〜3時間 初心者へのやさしさ 72/100
ls1intum/Artemis#14512 · コメント 1 件 ·
メンテナーはふだん 1 日以内に返信
-
kind:bug priority:major
難易度 2/5 1〜3時間 初心者へのやさしさ 76/100
メンテナーはふだん 1 日以内に返信
-
難易度 2/5 1〜3時間 初心者へのやさしさ 76/100
swiftlang/swift-java#955 ·
メンテナーはふだん 1 日以内に返信