Skip to content

[Bug]: JsonSchemaUtils shared SchemaGenerator is not thread-safe (ConcurrentModificationException under concurrent structured calls) #2795

Description

@jnduan

Describe the bug

JsonSchemaUtils initializes a single victools SchemaGenerator in a static
block (agentscope-core/src/main/java/io/agentscope/core/util/JsonSchemaUtils.java,
lines 62–84) and shares it across the whole JVM without any synchronization.
When multiple threads perform structured calls targeting the same output class
(e.g. multiple agents running in parallel), generateSchemaFromClass() /
generateSchemaFromType() can fail with a ConcurrentModificationException
wrapped in a RuntimeException.

The failure is timing-sensitive and intermittent: in our production setup (two
expert agents reviewing in parallel), it triggered once across many runs — the
losing agent fails immediately at its first structured call and returns an
ERROR, while the other agent completes normally.

To Reproduce

Steps to reproduce the behavior:

  1. Run two (or more) ReActAgent instances in parallel threads, both performing
    a structured call with the same target class:
    // thread A
    agentA.call(messages, MyReport.class, context);
    // thread B, overlapping in time
    agentB.call(messages, MyReport.class, context);
  2. Any overlap of two doStructuredCall invocations that both reach
    JsonSchemaUtils.generateSchemaFromClass(...) for a not-yet-introspected
    class races on the shared SchemaGenerator.
  3. Occasionally one thread throws (see error below).

Note: the race window is narrow — two threads must be introspecting the same
not-yet-cached class at the same time. We could not reproduce it
deterministically locally (800 concurrent calls across two stress rounds stayed
green), which matches the intermittent production behavior. The race is still
real: there is no happens-before edge between concurrent calls.

Expected behavior

Schema generation from shared static state should be thread-safe (or the class
should be explicitly documented as not thread-safe). Concurrent structured
calls are a normal usage pattern for multi-agent applications.

Error messages

java.lang.RuntimeException: Failed to generate JSON schema for com.example.MyStructuredOutput
    at io.agentscope.core.util.JsonSchemaUtils.generateSchemaFromClass(JsonSchemaUtils.java:102)
    at io.agentscope.core.ReActAgent.doStructuredCall(ReActAgent.java:1054)
    ...
Caused by: java.util.ConcurrentModificationException
    at java.base/java.util.ArrayList$Itr.checkForComodification(ArrayList.java:1013)
    at com.fasterxml.jackson.databind.introspect.POJOPropertiesCollector._sortProperties(POJOPropertiesCollector.java:1868)
    at com.fasterxml.jackson.databind.introspect.POJOPropertiesCollector.collectAll(POJOPropertiesCollector.java:502)
    at com.fasterxml.jackson.databind.introspect.BasicBeanDescription.findProperties(BasicBeanDescription.java:236)
    at com.github.victools.jsonschema.module.jackson.JacksonModule.shouldIgnoreField(JacksonModule.java:306)
    at com.github.victools.jsonschema.generator.SchemaGeneratorConfigPart.lambda$shouldIgnore$0(SchemaGeneratorConfigPart.java:135)

(stack trace trimmed to the relevant frames)

Environment (please complete the following information):

  • AgentScope-Java Version: 2.0.1 (also verified the code is unchanged on latest main, commit 0de9ed43)
  • Java Version: 17
  • OS: macOS

Additional context

Root cause analysis:

  1. Shared singleton without synchronizationJsonSchemaUtils holds a
    private static final SchemaGenerator (one instance per JVM); neither
    generateSchemaFromClass nor generateSchemaFromType guards the
    schemaGenerator.generateSchema(...) call.
  2. victools SchemaGenerator is not thread-safe — a known limitation of
    victools/jsonschema-generator. In jsonschema-module-jackson 4.38.0,
    JacksonModule keeps an instance-level Map<Class, BeanDescription>
    introspection cache (a plain HashMap, no concurrency protection).
    Concurrent getBeanDescriptionForClass calls can share one
    BeanDescription, and two threads racing on its lazily-populated
    POJOPropertiesCollector (one iterating in _sortProperties while the
    other inserts) throw the CME above.
  3. Wide trigger surfaceReActAgent.doStructuredCall regenerates the
    schema on every structured call (no caching), and the
    call(msgs, Class, ctx) entry always goes through the shared generator.
    So any two overlapping structured calls — not just agent startup, any ReAct
    iteration — form a data race. CME is only the most visible symptom; without
    a happens-before edge, the shared HashMap cache can also silently lose
    entries or get structurally corrupted.

The same class of bug was reported and fixed in Spring AI:
spring-ai#6207.
The Spring AI team resolved it by guarding every SchemaGenerator.generateSchema(...)
call with synchronized (commit 9b8eb07, "Guard JSON schema generation"),
plus concurrent regression tests.

Suggested fix: apply the same minimal guard in JsonSchemaUtils — wrap the two
schemaGenerator.generateSchema(...) call sites in a synchronized block
(a dedicated lock object, or synchronized (schemaGenerator) as Spring AI did).
This keeps the public API unchanged. A schema cache could be a separate
enhancement later.

Happy to submit a PR for this.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions