google / google/gson

`TypeAdapterRuntimeTypeWrapper` eagerly resolves the runtime-type adapter, so a failure building an adapter it would have discarded escapes to the caller

Open
#3,122 3 comments 0 reactions 0 assignees View on GitHub
bug
Dominant language
Java
Stars
24.2k
Forks
4.5k
Avg merge
6d 4h
Merged PRs (30d)
12

Description

## Gson version

2.14.0 (also reproduced on 2.12.1, 2.9.0 and 2.8.9)

## Java version

Reproduced on OpenJDK 21 and OpenJDK 26. Any JDK with the module system should be affected.

## Description

When a value is serialized as an element of an array or a collection, Gson wraps the
component's `TypeAdapter` in `TypeAdapterRuntimeTypeWrapper`. On write, that wrapper resolves an
adapter for the value's **runtime** type and only afterwards decides which of the two adapters to
use:

```java
// com/google/gson/internal/bind/TypeAdapterRuntimeTypeWrapper.java
TypeAdapter chosen = delegate;
Type runtimeType = getRuntimeTypeIfMoreSpecific(type, value);
if (runtimeType != type) {
TypeAdapter runtimeTypeAdapter = (TypeAdapter) context.getAdapter(TypeToken.get(runtimeType));
if (!(runtimeTypeAdapter instanceof ReflectiveTypeAdapterFactory.Adapter)) {
chosen = runtimeTypeAdapter;
} else if (!isReflective(delegate)) {
chosen = delegate; // <-- runtimeTypeAdapter is discarded here
} else {
chosen = runtimeTypeAdapter;
}
}
chosen.write(out, value);
```

The `context.getAdapter(...)` call is unconditional. When no adapter is registered for the runtime
type, Gson falls back to `ReflectiveTypeAdapterFactory`, whose construction throws `JsonIOException`
if a field of that class cannot be made accessible — for instance any class in a package that
`java.base` does not open.

In the `else if (!isReflective(delegate))` branch the result of that call is thrown away: the
user-registered adapter for the declared type is used instead. The exception is therefore raised
while computing a value the method has already decided not to use.

The practical consequence is that **registering an adapter for an interface is enough to serialize a
value directly, but not to serialize the same value inside an array or a collection**, as soon as the
implementation class is one Gson cannot reflect over. This makes `java.lang.reflect.Type` graphs
(whose implementations live in `sun.reflect.generics.reflectiveObjects`) impossible to serialize
without registering an adapter for every internal JDK class, even though a perfectly good adapter for
the declared interface is registered.

## Reproduction

```java
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.TypeAdapter;
import com.google.gson.reflect.TypeToken;
import com.google.gson.stream.JsonReader;
import com.google.gson.stream.JsonWriter;

import java.io.IOException;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.WildcardType;
import java.util.Arrays;
import java.util.List;

public class GsonRuntimeTypeLookupBug {

static class Holder {
List field;
}

/** A trivial adapter for WildcardType. It never uses reflection. */
static final TypeAdapter WILDCARD_ADAPTER = new TypeAdapter() {
@Override
public void write(JsonWriter out, WildcardType value) throws IOException {
out.value(value == null ? null : value.getTypeName());
}

@Override
public WildcardType read(JsonReader in) throws IOException {
in.nextString();
return null;
}
};

public static void main(String[] args) throws Exception {
ParameterizedType listOfWildcard =
(ParameterizedType) Holder.class.getDeclaredField("field").getGenericType();
WildcardType wildcard = (WildcardType) listOfWildcard.getActualTypeArguments()[0];
// wildcard.getClass() is sun.reflect.generics.reflectiveObjects.WildcardTypeImpl

Gson gson = new GsonBuilder()
.registerTypeAdapter(WildcardType.class, WILDCARD_ADAPTER)
.create();

// works
System.out.println(gson.toJson(wildcard, WildcardType.class));

// throws
System.out.println(gson.toJson(new WildcardType[]{wildcard}, WildcardType[].class));

// throws as well
System.out.println(gson.toJson(Arrays.asList(wildcard),
new TypeToken>() {}.getType()));
}
}
```

## Expected behaviour

All three calls serialize the wildcard with `WILDCARD_ADAPTER`, since it is registered for the
declared type and is not reflective — which is exactly what `TypeAdapterRuntimeTypeWrapper` decides
in its `else if (!isReflective(delegate))` branch.

## Actual behaviour

The first call succeeds. The other two fail:

```
"? extends java.lang.Number"

Exception in thread "main" com.google.gson.JsonIOException: Failed making field
'sun.reflect.generics.reflectiveObjects.WildcardTypeImpl#upperBounds' accessible;
either increase its visibility or write a custom TypeAdapter for its declaring type.
See https://github.com/google/gson/blob/main/Troubleshooting.md#reflection-inaccessible
at com.google.gson.internal.reflect.ReflectionHelper.makeAccessible(ReflectionHelper.java:52)
at com.google.gson.internal.bind.ReflectiveTypeAdapterFactory.getBoundFields(...)
at com.google.gson.internal.bind.ReflectiveTypeAdapterFactory.create(...)
at com.google.gson.Gson.getAdapter(Gson.java:557)
at com.google.gson.internal.bind.TypeAdapterRuntimeTypeWrapper.write(...)
```

The error message is also misleading here: it advises writing a custom `TypeAdapter` for the
declaring type, but one *is* registered for the declared interface, and the internal JDK class the
message names is not something a user can reasonably register an adapter for.

On Gson 2.8.9 the same reproduction fails with the underlying
`java.lang.reflect.InaccessibleObjectException` instead, so the behaviour predates the
`JsonIOException` wrapping added in 2.9.0.

## Suggested fix

Resolve the runtime-type adapter only when its outcome can actually be used, or tolerate its failure
when the declared-type delegate is going to be preferred anyway. For example:

```java
TypeAdapter chosen = delegate;
Type runtimeType = getRuntimeTypeIfMoreSpecific(type, value);
if (runtimeType != type) {
TypeAdapter runtimeTypeAdapter = null;
try {
runtimeTypeAdapter = (TypeAdapter) context.getAdapter(TypeToken.get(runtimeType));
} catch (JsonIOException e) {
// No usable adapter for the runtime type. Only rethrow if the delegate cannot serve either,
// i.e. if we would have chosen the runtime type adapter.
if (isReflective(delegate)) {
throw e;
}
}
if (runtimeTypeAdapter != null) {
...unchanged selection...
}
}
chosen.write(out, value);
```

This keeps the documented order of preference intact and only changes what happens when the third
preference — the reflective adapter for the runtime type — cannot be built at all.

## Workaround

Registering the adapter with `registerTypeHierarchyAdapter` instead of `registerTypeAdapter` avoids
the problem, because the lookup for the runtime type then returns the user adapter rather than a
reflective one:

```java
Gson gson = new GsonBuilder()
.registerTypeHierarchyAdapter(WildcardType.class, WILDCARD_ADAPTER)
.create();
```

Both the array and the collection then serialize as `["? extends java.lang.Number"]`.

The equivalent workaround for a `TypeAdapterFactory` is to match assignable types rather than the
exact `TypeToken`, so that the factory also answers for the runtime implementation class.

Contributor guide

Open the contributing guide

Research direction

Start with com/google/gson/internal/bind/TypeAdapterRuntimeTypeWrapper.java and trace its write method, especially the runtime adapter lookup and reflective-delegate selection. Run the supplied GsonRuntimeTypeLookupBug reproduction for direct, array, and collection serialization. Done means registered declared-type adapters serialize all three cases without an unnecessary inaccessible-reflection failure, while the existing adapter preference remains intact.

Written by the indexing model from the issue text.

Assessment

Tech stack
java
Domain
backend
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
72/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.