From 15859a9f13555356f9ecc5f54fded0496a094c16 Mon Sep 17 00:00:00 2001
From: duanjienan
Date: Fri, 21 Aug 2026 08:47:02 +0800
Subject: [PATCH 1/2] fix(core): make JsonSchemaUtils schema generation
thread-safe
The shared static victools SchemaGenerator is not thread-safe: its
JacksonModule keeps an unsynchronized introspection cache, so concurrent
structured calls can fail with ConcurrentModificationException. Guard
both schemaGenerator.generateSchema(...) call sites with a dedicated lock.
---
.../agentscope/core/util/JsonSchemaUtils.java | 21 +++-
.../core/util/JsonSchemaUtilsTest.java | 99 +++++++++++++++++++
2 files changed, 118 insertions(+), 2 deletions(-)
diff --git a/agentscope-core/src/main/java/io/agentscope/core/util/JsonSchemaUtils.java b/agentscope-core/src/main/java/io/agentscope/core/util/JsonSchemaUtils.java
index f15efe5984..703d7a4a8c 100644
--- a/agentscope-core/src/main/java/io/agentscope/core/util/JsonSchemaUtils.java
+++ b/agentscope-core/src/main/java/io/agentscope/core/util/JsonSchemaUtils.java
@@ -53,6 +53,10 @@
* {@code @JsonClassDescription(...)} - add class description
*
*
+ * All public methods are thread-safe. Schema generation through the shared victools
+ * {@code SchemaGenerator} is serialized by an internal lock, because the generator itself
+ * is not designed for concurrent use.
+ *
* @hidden
*/
public class JsonSchemaUtils {
@@ -61,6 +65,13 @@ public class JsonSchemaUtils {
private static final SchemaGenerator schemaGenerator;
+ /**
+ * Guards the shared victools {@link SchemaGenerator}, which is not thread-safe: its
+ * JacksonModule keeps an unsynchronized introspection cache, so concurrent schema
+ * generation must be serialized.
+ */
+ private static final Object SCHEMA_LOCK = new Object();
+
static {
// JacksonModule to support @JsonProperty, @JsonPropertyDescription annotations
JacksonModule jacksonModule =
@@ -95,7 +106,10 @@ public class JsonSchemaUtils {
*/
public static Map generateSchemaFromClass(Class> clazz) {
try {
- JsonNode schemaNode = schemaGenerator.generateSchema(clazz);
+ JsonNode schemaNode;
+ synchronized (SCHEMA_LOCK) {
+ schemaNode = schemaGenerator.generateSchema(clazz);
+ }
return JsonUtils.getJsonCodec()
.convertValue(schemaNode, new TypeReference
*
* @hidden
*/
@@ -68,10 +70,26 @@ public class JsonSchemaUtils {
/**
* Guards the shared victools {@link SchemaGenerator}, which is not thread-safe: its
* JacksonModule keeps an unsynchronized introspection cache, so concurrent schema
- * generation must be serialized.
+ * generation must be serialized. Only cache misses in {@link #CLASS_SCHEMA_CACHE} and
+ * {@link #TYPE_SCHEMA_CACHE} take this lock.
*/
private static final Object SCHEMA_LOCK = new Object();
+ /**
+ * Caches the schema {@link JsonNode} generated for each class, since it is a deterministic
+ * function of the class and the static, never-changing generator config, so no invalidation
+ * is needed. Values are never mutated after being cached; every call still converts a fresh,
+ * independently mutable {@code Map} from the cached node. Unbounded, but keys are the
+ * compile-time-fixed structured-output and tool-parameter classes declared by application
+ * code, so the entry count is bounded by the (small, finite) set of classes the JVM loads for
+ * that purpose, not by request volume or untrusted input.
+ */
+ private static final Map, JsonNode> CLASS_SCHEMA_CACHE = new ConcurrentHashMap<>();
+
+ /** Same caching strategy and bound rationale as {@link #CLASS_SCHEMA_CACHE}, keyed by
+ * generic {@link Type}. */
+ private static final Map TYPE_SCHEMA_CACHE = new ConcurrentHashMap<>();
+
static {
// JacksonModule to support @JsonProperty, @JsonPropertyDescription annotations
JacksonModule jacksonModule =
@@ -106,10 +124,14 @@ public class JsonSchemaUtils {
*/
public static Map generateSchemaFromClass(Class> clazz) {
try {
- JsonNode schemaNode;
- synchronized (SCHEMA_LOCK) {
- schemaNode = schemaGenerator.generateSchema(clazz);
- }
+ JsonNode schemaNode =
+ CLASS_SCHEMA_CACHE.computeIfAbsent(
+ clazz,
+ c -> {
+ synchronized (SCHEMA_LOCK) {
+ return schemaGenerator.generateSchema(c);
+ }
+ });
return JsonUtils.getJsonCodec()
.convertValue(schemaNode, new TypeReference>() {});
} catch (Exception e) {
@@ -144,10 +166,14 @@ public static Map generateSchemaFromJsonNode(JsonNode schema) {
*/
public static Map generateSchemaFromType(Type type) {
try {
- JsonNode schemaNode;
- synchronized (SCHEMA_LOCK) {
- schemaNode = schemaGenerator.generateSchema(type);
- }
+ JsonNode schemaNode =
+ TYPE_SCHEMA_CACHE.computeIfAbsent(
+ type,
+ t -> {
+ synchronized (SCHEMA_LOCK) {
+ return schemaGenerator.generateSchema(t);
+ }
+ });
return JsonUtils.getJsonCodec()
.convertValue(schemaNode, new TypeReference>() {});
} catch (Exception e) {
diff --git a/agentscope-core/src/test/java/io/agentscope/core/util/JsonSchemaUtilsTest.java b/agentscope-core/src/test/java/io/agentscope/core/util/JsonSchemaUtilsTest.java
index 0d62c2f107..bd3d341f65 100644
--- a/agentscope-core/src/test/java/io/agentscope/core/util/JsonSchemaUtilsTest.java
+++ b/agentscope-core/src/test/java/io/agentscope/core/util/JsonSchemaUtilsTest.java
@@ -149,6 +149,52 @@ void testConvertToObjectInvalidData() {
() -> JsonSchemaUtils.convertToObject(invalidData, SimpleModel.class));
}
+ @Test
+ void testGenerateSchemaFromClassRepeatedCallsReturnEqualIndependentMaps() {
+ Map first = JsonSchemaUtils.generateSchemaFromClass(SimpleModel.class);
+ Map second = JsonSchemaUtils.generateSchemaFromClass(SimpleModel.class);
+
+ assertEquals(first, second);
+
+ // Mutating a schema returned from one call must not leak into a later call, matching
+ // callers (e.g. ToolSchemaGenerator) that mutate the returned map in place.
+ first.put("description", "mutated");
+ assertTrue(!second.containsKey("description"));
+
+ Map third = JsonSchemaUtils.generateSchemaFromClass(SimpleModel.class);
+ assertTrue(!third.containsKey("description"));
+ assertEquals(second, third);
+ }
+
+ @Test
+ void testGenerateSchemaFromTypeRepeatedCallsReturnEqualIndependentMaps() {
+ Type listType = new TypeReference>() {}.getType();
+
+ Map first = JsonSchemaUtils.generateSchemaFromType(listType);
+ Map second = JsonSchemaUtils.generateSchemaFromType(listType);
+
+ assertEquals(first, second);
+
+ first.put("description", "mutated");
+ assertTrue(!second.containsKey("description"));
+
+ Map third = JsonSchemaUtils.generateSchemaFromType(listType);
+ assertTrue(!third.containsKey("description"));
+ assertEquals(second, third);
+ }
+
+ @Test
+ void testGenerateSchemaFromClassNullThrowsNullPointerException() {
+ assertThrows(
+ NullPointerException.class, () -> JsonSchemaUtils.generateSchemaFromClass(null));
+ }
+
+ @Test
+ void testGenerateSchemaFromTypeNullThrowsNullPointerException() {
+ assertThrows(
+ NullPointerException.class, () -> JsonSchemaUtils.generateSchemaFromType(null));
+ }
+
@Test
void testGenerateSchemaFromType() {
// Test List