Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions release-notes/CREDITS-2.x
Original file line number Diff line number Diff line change
Expand Up @@ -2102,3 +2102,8 @@ seonwoo_jung (@seonwooj0810)
* Fixed #6101: `@JsonInclude(NON_EMPTY, content=CUSTOM)` does not omit a Map property
after all entries are filtered
[2.21.6]

Sergey Lappo (@sergeylappo)
* Reported #6227: `BeanDescription` is not thread-safe when called concurrently on the
same instance (`findProperties()`, `findDefaultViews()`)
[2.21.8]
4 changes: 4 additions & 0 deletions release-notes/VERSION-2.x
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ Project: jackson-databind
#6206: Report cycle through `@JsonValue` accessor as `JsonMappingException`
(instead of `StackOverflowError`)
(fix by @pjfanning, w/ Claude code)
#6227: `BeanDescription` is not thread-safe when called concurrently on the
same instance (`findProperties()`, `findDefaultViews()`)
(reported by @sergeylappo)
(fix by @pjfanning, w/ Claude code)

2.21.7 (21-Sep-2026)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,9 @@ public static BasicBeanDescription forOtherUse(MapperConfig<?> config,
ac, Collections.<BeanPropertyDefinition>emptyList());
}

protected List<BeanPropertyDefinition> _properties() {
// [databind#6227]: synchronized to guard lazy initialization in case instance
// is shared across threads; returned List itself is NOT thread-safe
protected synchronized List<BeanPropertyDefinition> _properties() {
if (_properties == null) {
_properties = _propCollector.getProperties();
}
Expand Down Expand Up @@ -398,11 +400,11 @@ public JsonFormat.Value findExpectedFormat()
return _propCollector.getFormatOverrides();
}

// [databind#6227]: synchronized in case instance is shared across threads
@Override // since 2.9
public Class<?>[] findDefaultViews()
public synchronized Class<?>[] findDefaultViews()
{
if (!_defaultViewsResolved) {
_defaultViewsResolved = true;
Class<?>[] def = (_annotationIntrospector == null) ? null
: _annotationIntrospector.findViews(_classInfo);
// one more twist: if default inclusion disabled, need to force empty set of views
Expand All @@ -411,7 +413,10 @@ public Class<?>[] findDefaultViews()
def = NO_VIEWS;
}
}
// 22-Sep-2026: [databind#6227] MUST assign value before flag: otherwise another
// thread may see "resolved" flag set and return not-yet-assigned `null`
_defaultViews = def;
_defaultViewsResolved = true;
}
return _defaultViews;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,12 @@ public class POJOPropertiesCollector
/**
* State flag we keep to indicate whether actual property information
* has been collected or not.
*<p>
* NOTE: {@code volatile} so that the "collected" state (and,
* transitively, results assigned before it) is visible across threads in case
* a {@link BeanDescription} instance is shared, see [databind#6227].
*/
protected boolean _collected;
protected volatile boolean _collected;

/**
* Set of logical property information collected so far.
Expand Down Expand Up @@ -240,9 +244,12 @@ public Map<Object, AnnotatedMember> getInjectables() {
}

/**
* NOTE: {@code synchronized} since resolution of conflicting accessors
* modifies the accessor list (see [databind#6227]).
*
* @since 2.12
*/
public AnnotatedMember getJsonKeyAccessor() {
public synchronized AnnotatedMember getJsonKeyAccessor() {
if (!_collected) {
collectAll();
}
Expand All @@ -262,9 +269,12 @@ public AnnotatedMember getJsonKeyAccessor() {
}

/**
* NOTE: {@code synchronized} since resolution of conflicting accessors
* modifies the accessor list (see [databind#6227]).
*
* @since 2.9
*/
public AnnotatedMember getJsonValueAccessor()
public synchronized AnnotatedMember getJsonValueAccessor()
{
if (!_collected) {
collectAll();
Expand Down Expand Up @@ -425,11 +435,20 @@ public JsonFormat.Value getFormatOverrides() {

/**
* Internal method that will collect actual property information.
*<p>
* NOTE: {@code synchronized} since although instances are
* not designed to be shared across threads, if they are, concurrent collection
* would corrupt internal state (see [databind#6227]).
*
* @since 2.6
*/
protected void collectAll()
protected synchronized void collectAll()
{
// [databind#6227]: another thread may have completed collection while we
// were waiting for the lock
if (_collected) {
return;
}
_potentialCreators = new PotentialCreators();

// First: gather basic accessors
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
package com.fasterxml.jackson.databind.introspect;

import java.util.ArrayList;
import java.util.List;
import java.util.Queue;
import java.util.concurrent.*;

import org.junit.jupiter.api.Test;

import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonKey;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonValue;

import com.fasterxml.jackson.databind.BeanDescription;
import com.fasterxml.jackson.databind.JavaType;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.testutil.DatabindTestUtil;

import static org.junit.jupiter.api.Assertions.assertTrue;

// [databind#6227]
public class BeanDescriptionConcurrent6227Test extends DatabindTestUtil
{
static class Probe {
public final String id;
public final int count;

@JsonCreator
public Probe(@JsonProperty("id") String id, @JsonProperty("count") int count) {
this.id = id;
this.count = count;
}
}

// Both field and getter annotated: resolution modifies accessor list
static class ValueProbe {
@JsonValue
public String value = "x";

@JsonValue
public String getValue() { return value; }
}

static class KeyProbe {
@JsonKey
public String key = "x";

@JsonKey
public String getKey() { return key; }
}

@FunctionalInterface
interface DescAction {
void run(BeanDescription desc) throws Exception;
}

@Test
public void testConcurrentFindProperties() throws Exception
{
ObjectMapper mapper = newJsonMapper();
JavaType type = mapper.constructType(Probe.class);

// Repeat a number of times to increase odds of hitting race
for (int round = 0; round < 50; ++round) {
final BeanDescription shared = mapper.getSerializationConfig().introspect(type);
_runRound(shared, 16, BeanDescription::findProperties);
}
}

@Test
public void testConcurrentFindJsonValueAccessor() throws Exception
{
ObjectMapper mapper = newJsonMapper();
JavaType type = mapper.constructType(ValueProbe.class);

for (int round = 0; round < 50; ++round) {
final BeanDescription shared = mapper.getSerializationConfig().introspect(type);
// Getter has precedence over field
_runRound(shared, 16, desc -> assertTrue(
desc.findJsonValueAccessor() instanceof AnnotatedMethod));
}
}

@Test
public void testConcurrentFindJsonKeyAccessor() throws Exception
{
ObjectMapper mapper = newJsonMapper();
JavaType type = mapper.constructType(KeyProbe.class);

for (int round = 0; round < 50; ++round) {
final BeanDescription shared = mapper.getSerializationConfig().introspect(type);
// Getter has precedence over field
_runRound(shared, 16, desc -> assertTrue(
desc.findJsonKeyAccessor() instanceof AnnotatedMethod));
}
}

private void _runRound(final BeanDescription shared, int parallelism,
final DescAction action) throws Exception
{
final CyclicBarrier barrier = new CyclicBarrier(parallelism);
final Queue<Throwable> errors = new ConcurrentLinkedQueue<>();
ExecutorService pool = Executors.newFixedThreadPool(parallelism);
List<Future<?>> futures = new ArrayList<>();
for (int i = 0; i < parallelism; i++) {
futures.add(pool.submit(() -> {
try {
barrier.await();
action.run(shared);
} catch (Throwable e) {
errors.add(e);
}
}));
}
for (Future<?> f : futures) {
f.get(30, TimeUnit.SECONDS);
}
pool.shutdown();
if (!errors.isEmpty()) {
throw new AssertionError("Failed with "+errors.size()+" errors, first: "+errors.peek(), errors.peek());
}
assertTrue(errors.isEmpty());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package com.fasterxml.jackson.databind.introspect;

import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicReference;

import org.junit.jupiter.api.Test;

import com.fasterxml.jackson.annotation.JsonView;

import com.fasterxml.jackson.databind.BeanDescription;
import com.fasterxml.jackson.databind.JavaType;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.testutil.DatabindTestUtil;

import static org.junit.jupiter.api.Assertions.*;

// [databind#6227] `findDefaultViews()` set its "resolved" flag BEFORE computing the
// value, so a second thread could observe the flag and return `null` views.
public class BeanDescriptionDefaultViewsRaceTest extends DatabindTestUtil
{
static class Views { static class Pub { } }

@JsonView(Views.Pub.class)
static class ViewedBean {
public String a = "a";
public String b = "b";
}

/**
* Introspector that blocks inside the very first {@code findViews()} call, holding
* open the window between the flag write and the value write.
*/
static class BlockingIntrospector extends JacksonAnnotationIntrospector
{
private static final long serialVersionUID = 1L;

final CountDownLatch inside = new CountDownLatch(1);
final CountDownLatch release = new CountDownLatch(1);
final Semaphore firstCall = new Semaphore(1);

@Override
public Class<?>[] findViews(Annotated a) {
if (firstCall.tryAcquire()) { // only the first caller blocks
inside.countDown();
try {
release.await(10, TimeUnit.SECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
return super.findViews(a);
}
}

@Test
public void testConcurrentFindDefaultViews() throws Exception
{
final BlockingIntrospector intr = new BlockingIntrospector();
ObjectMapper mapper = newJsonMapper();
mapper.setAnnotationIntrospector(intr);
JavaType type = mapper.constructType(ViewedBean.class);
final BeanDescription desc = mapper.getSerializationConfig().introspect(type);

final AtomicReference<Class<?>[]> fromA = new AtomicReference<>();
final AtomicReference<Class<?>[]> fromB = new AtomicReference<>();

ExecutorService pool = Executors.newFixedThreadPool(2);
try {
Future<?> a = pool.submit(() -> fromA.set(desc.findDefaultViews()));
// wait until thread A is INSIDE findViews(), i.e. mid-resolution
assertTrue(intr.inside.await(10, TimeUnit.SECONDS), "thread A never reached findViews()");
Future<?> b = pool.submit(() -> fromB.set(desc.findDefaultViews()));
// give B a chance to read the half-resolved state, then let A finish
Thread.sleep(100L);
intr.release.countDown();
a.get(10, TimeUnit.SECONDS);
b.get(10, TimeUnit.SECONDS);
} finally {
pool.shutdownNow();
}

assertNotNull(fromA.get(), "views resolved by first thread should not be null");
assertNotNull(fromB.get(),
"second thread saw the 'resolved' flag before the value was assigned, and got null views");
assertArrayEquals(fromA.get(), fromB.get());
assertEquals(Views.Pub.class, fromB.get()[0]);
}
}