Skip to content

Fix CompatibleFieldSerializer failing to skip removed generic collection/map fields#1287

Open
theigl wants to merge 4 commits into
masterfrom
fix/compatible-field-serializer-generic-collection-removal
Open

Fix CompatibleFieldSerializer failing to skip removed generic collection/map fields#1287
theigl wants to merge 4 commits into
masterfrom
fix/compatible-field-serializer-generic-collection-removal

Conversation

@theigl

@theigl theigl commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator

Summary

Fixes #1098, also fixes #907 (same root cause).

Root cause

When readUnknownFieldData=true (the default), CompatibleFieldSerializer.write() calls ReflectField.write() which pushes the field's declared generic type (e.g. List<String>) before invoking the serializer. CollectionSerializer and MapSerializer detect the pushed type via nextGenericClass() / nextGenericTypes() and choose a compact binary format with no per-element class information.

On the read side, when the field has been removed from the class, the unknown-field skip path called kryo.readObject() without pushing any GenericType. The serializer therefore chose the self-describing format and tried to read class-per-element headers that were never written, corrupting the stream and throwing a KryoException.

Fix

When readUnknownFieldData=true, the serializer now writes each field's generic type arguments alongside its name in the schema section (a numArgs VarInt followed by class registrations for each resolved type argument). On the read side, readFields() loads these stored arguments and the unknown-field skip path reconstructs a GenericType from them before calling readObject(), ensuring CollectionSerializer/MapSerializer use the same compact format that was written.

A null-arg guard in readFieldTypeArgs() handles wildcard/type-variable arguments (e.g. List<?>) by suppressing the GenericType push entirely — for those fields both writer and reader already use self-describing format, so no push is needed and none is safe (it would cause a NPE inside DefaultGenerics.nextGenericClass()).

Trade-off

This is a wire-format breaking change for the readUnknownFieldData=true path. Data written by older Kryo versions cannot be read by this version and vice versa. The existing behaviour for chunked=true (catch exception + nextChunk()) already handled stream recovery and remains the recommended approach for users who need to continue reading pre-existing serialized data.

Test plan

🤖 Generated with Claude Code

theigl and others added 3 commits June 23, 2026 09:21
…ion fields (#1098)

When readUnknownFieldData=true (the default), the writer pushes a field's
declared generic type (e.g. List<String>) before calling CollectionSerializer,
which selects a compact binary format with no per-element class info. The
reader's unknown-field skip path did not push any generic type before calling
kryo.readObject(), so CollectionSerializer chose the self-describing format and
misread the compact bytes, causing a KryoException.

Fix: store each field's generic type arguments alongside its name in the
serialized schema. When skipping an unknown field during read, reconstruct and
push a GenericType from the stored arguments so CollectionSerializer uses the
same compact format that was written.

Note: this changes the schema format for the readUnknownFieldData=true path
and is not wire-compatible with data written by older versions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
#907: removing a Map<K,V> field caused the same corrupt-read as #1098 —
the two-argument GenericType is now stored in the schema and reconstructed
on the read side, so MapSerializer uses the same compact key/value encoding
on both paths.

#834: the chunked+references variant of the generic-collection-removal bug
(the root cause theigl noted as "another issue that I cannot fix at this
point") is now covered. The InputChunked.nextChunk fix already recovered
from the stream corruption; this test verifies the root cause is gone.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
readFieldTypeArgs now returns null early if any type argument was stored as
null (i.e. written from a wildcard or type variable). Without this guard,
the caller would push a GenericType with a null slot in its arguments array,
causing DefaultGenerics.nextGenericClass() to NPE on arguments[0].resolve().

Returning null suppresses the GenericType push entirely, so the skipping
reader falls back to the self-describing format — which matches the writer,
because for unresolvable type params CollectionSerializer already chose
self-describing format at write time.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…d-serializer-generic-collection-removal

# Conflicts:
#	src/com/esotericsoftware/kryo/serializers/CompatibleFieldSerializer.java
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

1 participant