Fix generics stack leak that poisoned reused Kryo instances#1283
Merged
theigl merged 1 commit intoJun 17, 2026
Merged
Conversation
5ef280c to
9519df0
Compare
There was a problem hiding this comment.
Pull request overview
Fixes a long-lived state leak in Kryo’s generics tracking where exceptions during field (de)serialization could leave entries on the generics/type-variable stacks, poisoning reused Kryo instances across subsequent operations.
Changes:
- Add
Generics.reset()and invoke it fromKryo.reset()to centrally clear leaked generics/type-variable stack state. - Make
ReflectField.read/writeexception-safe by movingpopGenericType()intofinally. - Add
GenericsResetTestto reproduce the failure and assert correct behavior after both read- and write-time exceptions.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
test/com/esotericsoftware/kryo/serializers/GenericsResetTest.java |
New end-to-end regression tests covering generics stack leaks on exception for both read and write paths. |
src/com/esotericsoftware/kryo/util/NoGenerics.java |
Adds reset() no-op implementation for the no-generics mode. |
src/com/esotericsoftware/kryo/util/Generics.java |
Introduces reset() API for clearing tracked generic info (called from Kryo.reset()). |
src/com/esotericsoftware/kryo/util/DefaultGenerics.java |
Implements stack-clearing reset() for the default generics tracker. |
src/com/esotericsoftware/kryo/serializers/ReflectField.java |
Ensures generics stack is popped via finally even when nested (de)serialization throws. |
src/com/esotericsoftware/kryo/Kryo.java |
Calls generics.reset() as part of Kryo.reset() to prevent stale generics from surviving reuse. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
An exception thrown while (de)serializing a field left entries on the generics stacks: ReflectField skipped its popGenericType, and FieldSerializer skipped popTypeVariables (both are outside a finally). Kryo.reset() did not clear these stacks, so the leaked state survived autoReset and poisoned a reused (thread-local or pooled) Kryo instance: a later, unrelated (de)serialization could fail with an ArrayIndexOutOfBoundsException. - Add Generics.reset(), called from Kryo.reset(), clearing both the genericTypes and the type-variable (arguments) stacks. It has a fast path that does nothing when the stacks are already empty, since it runs on every serialization. - Move popGenericType to a finally block in ReflectField read/write, the most likely source of serialization exceptions. Issue: EsotericSoftware#1281
9519df0 to
a5f5d22
Compare
Contributor
Author
|
Thanks for the review. Addressed both points:
|
Collaborator
|
That looks great! Thank you @Icesource. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #1281.
Problem
When a serializer throws while (de)serializing a field, entries are left on Kryo's generics stacks:
ReflectFieldskips itspopGenericType(it was after the nested read/write, not in afinally).FieldSerializerskipspopTypeVariables(it is after the field loop, outside thetry/catch).Kryo.reset()did not clear these stacks, so the leaked state survivesautoResetand poisons a reused (thread-local or pooled)Kryoinstance — a later, unrelated (de)serialization then mistakes the stale generic for its own and fails with a misleadingArrayIndexOutOfBoundsException. The instance stays broken for the rest of its lifetime.Fix
Following the approach suggested in the issue:
Generics.reset(), called fromKryo.reset()— centralized so it always runs, even if afinallyis forgotten somewhere. It has a fast path that returns immediately when the stacks are already empty (the normal case after a balanced serialization), so the per-serialization cost is negligible. The slow path nulls out the leaked entries and zeroes the sizes. Both thegenericTypesstack and the type-variable (arguments) stack are cleared, since both leak on exception.NoGenericsgets a no-op.popGenericTypemoved to afinallyinReflectField.read/write— the most likely source of serialization exceptions — so the common case keeps the stack balanced without relying onreset().popGenericTypeis already safe to call when nothing was pushed.Tests
GenericsResetTestcovers both directions end-to-end:List<Item>field, followed by an unrelatedHashMapwith two non-empty nested maps. Before the fix the generics stack is left at size 2 and the second read throwsArrayIndexOutOfBoundsException; after the fix the stack is empty and the second read succeeds.Both fail on
masterwithout the fix and pass with it.Full suite: the only failure is
DefaultSerializersTest#testLocaleSerializer(zh_CNvszh_CN_#Hans), a pre-existing locale-data difference in my environment that also fails on unmodifiedmaster.