Skip to content

fix(java): use REPLACE_STUB_ID for unregistered writeReplace classes to prevent cross-JVM ClassNotFoundException#3638

Open
wakilurislam wants to merge 1 commit intoapache:mainfrom
wakilurislam:fix/write-replace-cross-jvm
Open

fix(java): use REPLACE_STUB_ID for unregistered writeReplace classes to prevent cross-JVM ClassNotFoundException#3638
wakilurislam wants to merge 1 commit intoapache:mainfrom
wakilurislam:fix/write-replace-cross-jvm

Conversation

@wakilurislam
Copy link
Copy Markdown

@wakilurislam wakilurislam commented Apr 29, 2026

PR Description

Why?

When Fory serializes an unregistered class with writeReplace() returning a different type (e.g., a Hibernate/ByteBuddy proxy), the outer type info writes the proxy class name to the byte stream. On a different JVM where that proxy class doesn't exist, deserialization fails with ClassNotFoundException.

What does this PR do?

Adds an instanceof ReplaceResolveSerializer check in ClassResolver.buildUnregisteredTypeId() to return REPLACE_STUB_ID instead of NAMED_EXT. This writes a compact 1-byte internal type ID without the class name, matching the existing pattern for lambdas (LAMBDA_STUB_ID) and JDK proxies (JDK_PROXY_STUB_ID).

The original NAMED_EXT fallback for serializer == null (before serializer creation) is preserved unchanged.

Changes

ClassResolver.java:

  1. buildUnregisteredTypeId() — return REPLACE_STUB_ID when serializer is ReplaceResolveSerializer:
   protected int buildUnregisteredTypeId(Class<?> cls, Serializer<?> serializer) {
-    if (serializer == null && !cls.isEnum() && useReplaceResolveSerializer(cls)) {
-      return Types.NAMED_EXT;
+    if (!cls.isEnum()) {
+      if (serializer instanceof ReplaceResolveSerializer) {
+        return REPLACE_STUB_ID;
+      }
+      if (serializer == null && useReplaceResolveSerializer(cls)) {
+        return Types.NAMED_EXT;
+      }
     }
  1. addSerializer() — guard against overwriting typeIdToTypeInfo[REPLACE_STUB_ID]:
+      if (typeId == REPLACE_STUB_ID) {
+        classInfoMap.put(type, typeInfo);
+      } else {
+        updateTypeInfo(type, typeInfo);
+      }

WriteReplaceCrossJvmTest.java (new): 6 test methods × 4 configurations = 24 tests covering cross-JVM proxy, same-JVM regression, same-type replace, round-trip, and nested proxy in DTO.

Related issues

AI Contribution Checklist

  • Substantial AI assistance was used in this PR: yes
AI Usage Disclosure
- substantial_ai_assistance: yes
- scope: code drafting, tests, design analysis
- affected_files_or_subsystems: java/fory-core ClassResolver.java, WriteReplaceCrossJvmTest.java (new)
- ai_review: two fresh AI reviewers on current HEAD — both found 0 actionable issues.
- human_verification: full fory-core test suite (1820 tests, 0 failures). ObjectStreamSerializerTest (82), URLSerializerTest (9), ReplaceResolveSerializerTest (58) all pass. Cross-JVM test fails without fix (4 failures), passes with fix (24/24).
- performance_verification: N/A — one-time cold-path change only
- provenance_license_confirmation: Apache-2.0 compatible, all original work.

Does this PR introduce any user-facing change?

No public API or binary protocol changes. Wire format changes only for unregistered ReplaceResolveSerializer classes (bug fix — previous format was broken for cross-JVM).

Benchmark

N/A — one-time buildUnregisteredTypeId() call, not per-element.

review_1_with_skill.md
review_2_without_skill.md

@wakilurislam wakilurislam marked this pull request as draft April 29, 2026 12:47
@wakilurislam wakilurislam changed the title fix: use REPLACE_STUB_ID for unregistered writeReplace classes to pre… fix(java): use REPLACE_STUB_ID for unregistered writeReplace classes to prevent cross-JVM ClassNotFoundException Apr 29, 2026
@wakilurislam wakilurislam force-pushed the fix/write-replace-cross-jvm branch 2 times, most recently from 1047281 to 3348c99 Compare April 29, 2026 13:44
…vent cross-JVM ClassNotFoundException

When Fory serializes an unregistered class with writeReplace() (e.g. a
Hibernate proxy), the outer type info previously used NAMED_EXT which
writes the proxy class name to the byte stream. On a different JVM where
that proxy class doesn't exist, deserialization fails with
ClassNotFoundException before ReplaceResolveSerializer can handle the
replacement.

Fix: return REPLACE_STUB_ID from buildUnregisteredTypeId() for classes
detected as writeReplace candidates. This writes a compact 1-byte
internal type ID instead of the class name, matching the existing pattern
used for lambdas (LAMBDA_STUB_ID) and JDK proxies (JDK_PROXY_STUB_ID).

Also guard addSerializer() to avoid overwriting the pre-registered
typeIdToTypeInfo[REPLACE_STUB_ID] entry when adding per-class TypeInfo.
@wakilurislam wakilurislam force-pushed the fix/write-replace-cross-jvm branch from 3348c99 to fce16aa Compare April 29, 2026 13:45
@wakilurislam wakilurislam marked this pull request as ready for review April 29, 2026 13:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Java] Unregistered writeReplace classes cause ClassNotFoundException on cross-JVM deserialization

1 participant