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

Cerrado Apto para principiantes
#3,400 0 comentarios 0 reacciones 0 asignados Ver en GitHub

Los mantenedores suelen responder en 9 días

@awinishere ya está trabajando en esto.

Desde el 1/11/2026.

  • #3402 de @awinishere — abierto

Evaluación

Dificultad
2/5
Tiempo estimado
1-3 horas
Aptitud para principiantes
85/100
Tipo de issue
Error
Claridad
Bien especificado
Estado de actividad
Activo
Stack tecnológico
java, kotlin

Línea de trabajo

Empieza con SchemaPropertyDeprecatingConverter.isDeprecated y la declaración de DEPRECATED_ANNOTATIONS en springdoc-openapi-starter-common; compara el comportamiento de sus colecciones con el de las colecciones de copia en escritura mencionadas en el issue. Usa la reproducción descrita en el issue para comprobar el registro concurrente y el registro repetido. El trabajo está terminado cuando las llamadas concurrentes no lanzan excepciones y el registro repetido deja solo los dos tipos de anotación esperados.

Escrito por el modelo de indexación a partir del texto del issue.

Descripción

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.

Lenguaje dominante
Java
Estrellas
3.7k
Forks
605
Merge medio
9 d 11 h
PR fusionados (30 d)
14

Preparar el entorno

Este proyecto no incluye contenedor de desarrollo, Dockerfile ni guía de contribución, así que la configuración corre por tu cuenta: empieza por su README y consulta nuestra guía para la primera contribución para los pasos generales.

Primeros pasos

  1. Lee el issue completo y luego la guía de contribución del proyecto.
  2. Comenta en el issue que vas a ocuparte — evita que dos personas hagan lo mismo.
  3. Haz un fork del repositorio y trabaja en una rama.
  4. Abre un pull request que haga referencia al número del issue.

Más de springdoc/springdoc-openapi

Todos los issues de springdoc/springdoc-openapi

Issues similares

Más issues de Java

Recibe los nuevos issues en tu correo

Un resumen breve de issues de GitHub para principiantes.