`ConcurrentModificationException` in `SchemaPropertyDeprecatingConverter.isDeprecated`, and `DEPRECATED_ANNOTATIONS` gains a duplicate on every context start

Fermée Adaptée aux débutants
#3,400 0 commentaires 0 réactions 0 personnes assignées Voir sur GitHub

Les mainteneurs répondent en général sous 1 jour

@awinishere y travaille déjà.

Depuis le 1/11/2026.

  • #3402 par @awinishere — ouverte

Évaluation

Difficulté
2/5
Temps estimé
1-3 heures
Accessibilité débutants
85/100
Type d'issue
Bug
Clarté
Clairement spécifiée
Activité
Active
Stack technique
java, kotlin

Piste de recherche

Commencez par SchemaPropertyDeprecatingConverter.isDeprecated et la déclaration de DEPRECATED_ANNOTATIONS dans springdoc-openapi-starter-common ; comparez le comportement de leurs collections à celui des collections copy-on-write mentionnées dans l’issue. Utilisez la reproduction décrite dans l’issue pour vérifier l’enregistrement concurrent et les enregistrements répétés. Le travail est terminé lorsque les appels concurrents ne déclenchent pas d’exception et que les enregistrements répétés ne laissent que les deux types d’annotation attendus.

Rédigé par le modèle d'indexation à partir du texte de l'issue.

Description

Describe the bug

SchemaPropertyDeprecatingConverter keeps the deprecated annotation types in a JVM-wide static Collections.synchronizedList. Two problems in springdoc-openapi-starter-common:

  1. isDeprecated(Method) calls DEPRECATED_ANNOTATIONS.stream() without holding the list's lock. A synchronized list only synchronizes its own methods, and the Javadoc of Collections.synchronizedList requires a manual lock for traversal by iterator, spliterator or stream. A concurrent add makes the stream throw a ConcurrentModificationException.
  2. SpringDocKotlinConfiguration's init block calls SpringDocUtils.getConfig().addDeprecatedType(Deprecated::class.java) on every application context start. The list never deduplicates, so it grows by one entry per context. Every isDeprecated call (once per handler method per /v3/api-docs generation) 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-common 3.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

  • isDeprecated does 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.Deprecated and kotlin.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.

Langage dominant
Java
Étoiles
3.7k
Forks
605
Merge moyen
4 j 18 h
PR mergées (30 j)
21

Préparer son environnement

Ce projet ne fournit ni conteneur de développement, ni Dockerfile, ni guide de contribution : l'installation est à votre charge. Commencez par son README, et consultez notre guide de la première contribution pour les étapes générales.

Par où commencer

  1. Lisez l'issue en entier, puis le guide de contribution du projet.
  2. Signalez en commentaire que vous la prenez — cela évite que deux personnes fassent le même travail.
  3. Forkez le dépôt et travaillez sur une branche.
  4. Ouvrez une pull request qui référence le numéro de l'issue.

Autres issues de springdoc/springdoc-openapi

Toutes les issues de springdoc/springdoc-openapi

Issues similaires

Plus d'issues Java

Recevez les nouvelles issues par e-mail

Un résumé court des issues GitHub adaptées aux débutants.