Skip to content

Restore serialization support for TypeDescriptor - #37109

Open
junhyeong9812 wants to merge 1 commit into
spring-projects:mainfrom
junhyeong9812:fix/typedescriptor-serialization
Open

Restore serialization support for TypeDescriptor#37109
junhyeong9812 wants to merge 1 commit into
spring-projects:mainfrom
junhyeong9812:fix/typedescriptor-serialization

Conversation

@junhyeong9812

Copy link
Copy Markdown
Contributor

Overview

TypeDescriptor has declared Serializable since 3.0, but instances created from a Field, a MethodParameter or a Property can no longer be serialized. Writing such an instance fails with NotSerializableException.

This is a regression from gh-33948 (1a573d6e3c7, "Lazily retrieve TypeDescriptor annotations on demand"). Serialization still works in 6.2.1 and fails in 6.2.13, and the current code on 7.0.x and main behaves the same as 6.2.13.

Problem

That commit replaced the eagerly resolved AnnotatedElementAdapter field with a serializable lambda:

private final AnnotatedElementSupplier annotatedElementSupplier;

public TypeDescriptor(Field field) {
    this.resolvableType = ResolvableType.forField(field);
    this.type = this.resolvableType.resolve(field.getType());
    this.annotatedElementSupplier = () -> AnnotatedElementAdapter.from(field.getAnnotations());
}

AnnotatedElementSupplier extends Serializable, so the lambda is written to the stream as a SerializedLambda along with everything it captures. The captured values are the very reflection objects the descriptor was created from, and none of them are serializable:

Constructor Captured value Serializable
TypeDescriptor(Field) java.lang.reflect.Field no
TypeDescriptor(MethodParameter) MethodParameter no
TypeDescriptor(Property) Property no
TypeDescriptor(ResolvableType, Class, Annotation[]) Annotation[] yes

Reproduction:

Field field = MyBean.class.getDeclaredField("name");
new ObjectOutputStream(out).writeObject(new TypeDescriptor(field));
// java.io.NotSerializableException: java.lang.reflect.Field

The failure occurs even when the annotations have already been resolved, since the supplier field itself is neither transient nor cleared once the adapter has been cached.

The existing TypeDescriptorTests.serializable() test did not catch this because TypeDescriptor.forObject("") routes to the (ResolvableType, Class, Annotation[]) constructor with a null annotation array, which is the one path that still serializes.

Descriptors reaching a serialization boundary are not exotic: ConversionFailedException and ConverterNotFoundException both hold non-transient TypeDescriptor fields, and those exceptions are serializable by virtue of being Throwable.

Fix

Mark the supplier transient and let the already serializable AnnotatedElementAdapter carry the annotations across the stream instead:

private transient AnnotatedElementSupplier annotatedElementSupplier;

private void writeObject(ObjectOutputStream outputStream) throws IOException {
    // Resolve the annotations up front since the supplier is transient: it captures
    // the Field/MethodParameter/Property this descriptor has been created from.
    getAnnotatedElement();
    outputStream.defaultWriteObject();
}

private void readObject(ObjectInputStream inputStream) throws IOException, ClassNotFoundException {
    inputStream.defaultReadObject();
    AnnotatedElementAdapter annotatedElement = AnnotatedElementAdapter.from(
            this.annotatedElement != null ? this.annotatedElement.getAnnotations() : null);
    this.annotatedElement = annotatedElement;
    this.annotatedElementSupplier = () -> annotatedElement;
}

Notes on the two hooks:

  • writeObject() resolves the annotations only at serialization time, so the lazy retrieval introduced by Expression performance regression due to missing annotation types on context classes #33948 is preserved for every other code path. Without it, a descriptor that has never been asked for its annotations would write a null adapter and lose them.
  • readObject() runs the restored adapter through AnnotatedElementAdapter.from(...) so that an empty adapter is folded back into the shared EMPTY instance. AnnotatedElementAdapter.isEmpty() is an identity check and the class has no readResolve(), so without this the isEmpty() shortcut in hasAnnotation() and getAnnotation() would be lost after a round trip.

Tests cover all four constructors, a descriptor without annotations, a derived descriptor, and the lazy-versus-serialization timing.

Note on impact

Making the field private transient removes it from the default serialVersionUID computation, so the computed UID changes (-4882614078662365050 to 1724818276882560505, serialized fields 4 to 3). Streams written by an earlier version are therefore rejected with InvalidClassException rather than being read.

Pinning the previous UID was considered and deliberately not done: with a matching UID the old supplier field would be read and discarded, annotatedElement would be null because the old code never forced resolution, and the annotations would be lost silently. TypeDescriptor does not declare a serialVersionUID and carries @SuppressWarnings("serial"), so cross-version stream compatibility was never part of its contract, and failing loudly seems preferable to dropping annotations quietly. Note also that streams containing a Field, MethodParameter or Property based descriptor cannot exist today, since writing them is exactly what fails.

One behavioural detail worth flagging: annotation resolution can now surface during serialization, so a missing annotation class shows up as TypeNotPresentException from writeObject() where it previously surfaced as NotSerializableException. Both cases fail, and the former carries more information about the cause.

Unrelated to this change, TypeDescriptor.getElementTypeDescriptor() results still fail to serialize with NotSerializableException: sun.reflect.generics.reflectiveObjects.TypeVariableImpl, before and after this fix. That one originates in the ResolvableType held by the descriptor rather than in the annotation supplier, so it is left alone here.

TypeDescriptor declares Serializable, but instances created from a
Field, a MethodParameter or a Property fail to serialize since the
annotation lookup has been deferred to a serializable lambda that
captures those non-serializable reflection objects.

Mark the supplier transient, resolve the annotations in writeObject()
so that the already serializable AnnotatedElementAdapter carries them,
and rebuild the supplier from that adapter in readObject(), folding an
empty adapter back into the shared EMPTY instance.

See spring-projectsgh-33948

Signed-off-by: junhyeong9812 <pickjog@gmail.com>
@spring-projects-issues spring-projects-issues added the status: waiting-for-triage An issue we've not yet triaged or decided on label Aug 4, 2026
@sbrannen sbrannen added the in: core Issues in core modules (aop, beans, core, context, expression) label Aug 4, 2026
@sbrannen
sbrannen requested a review from jhoeller August 4, 2026 07:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

in: core Issues in core modules (aop, beans, core, context, expression) status: waiting-for-triage An issue we've not yet triaged or decided on

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants