Skip to content

Java hidden-class field access can fail during async serializer JIT callback #3790

Description

@mandrean

Search before asking

  • I searched the open issues and found no similar issue.

Version

  • Fory: 1.2.0.
  • Component: Java runtime.
  • JDK: reproduced in the hidden generated serializer path used on JDK 25. The application-facing failure is normal Fory serialization of a nested object graph.

Component(s)

Java

Minimal reproduce step

The regression was observed after adding an explicit allow-all TypeChecker to this existing setup:

private static final TypeChecker ALLOW_ALL_TYPES = (resolver, className) -> true;

Fory.builder()
    .withXlang(false)
    .requireClassRegistration(false)
    .withRefTracking(true)
    .withAsyncCompilation(true)
    .withCompatible(false)
    .withTypeChecker(ALLOW_ALL_TYPES)
    .build();

The same test scenario passed without .withTypeChecker(ALLOW_ALL_TYPES) and failed with it. The TypeChecker does not seem to be the field-access bug itself; it changes the configured class-checking path enough to expose the async generated serializer callback failure.

The real-world shape is an ordinary class with nested object fields. The user model does not declare a hidden field. The hidden class is Fory's generated serializer class, for example a generated ContainerPayloadForyCodec_0/0x... class. That generated serializer has ordinary Serializer fields such as serializer and serializer1, and async JIT swaps those fields when nested field serializers finish compiling.

A focused regression test can be added as java/fory-core/src/test/java/org/apache/fory/builder/ForyHiddenSerializerFieldTest.java:

package org.apache.fory.builder;

import static org.testng.Assert.assertEquals;
import static org.testng.Assert.assertTrue;

import org.apache.fory.Fory;
import org.apache.fory.config.ForyBuilder;
import org.apache.fory.reflect.ReflectionUtils;
import org.apache.fory.resolver.TypeChecker;
import org.apache.fory.serializer.Serializer;
import org.testng.annotations.Test;

public class ForyHiddenSerializerFieldTest {
  private static final TypeChecker ALLOW_ALL_TYPES = (resolver, className) -> true;

  @Test(timeOut = 60000)
  public void testAsyncCompilationSwitchAllowAllTypes() throws InterruptedException {
    ForyBuilder builder =
        Fory.builder()
            .withXlang(false)
            .requireClassRegistration(false)
            .withRefTracking(true)
            .withAsyncCompilation(true)
            .withCompatible(false)
            .withTypeChecker(ALLOW_ALL_TYPES);
    Fory fory = builder.build();

    ContainerPayload value =
        new ContainerPayload(new NestedPayload(1, "name"), new PayloadDetails("category", true));

    assertRoundTrip(fory, value);

    Class<?>[] nestedTypes = {NestedPayload.class, PayloadDetails.class};
    for (Class<?> cls : nestedTypes) {
      while (!(fory.getTypeResolver().getSerializer(cls) instanceof Generated)) {
        Thread.sleep(1000);
      }
    }

    while (fory.getJITContext().hasJITResult(NestedPayload.class)) {
      Thread.sleep(10);
    }
    while (fory.getJITContext().hasJITResult(PayloadDetails.class)) {
      Thread.sleep(10);
    }

    Serializer<ContainerPayload> serializer =
        fory.getTypeResolver().getSerializer(ContainerPayload.class);

    assertTrue(ReflectionUtils.getObjectFieldValue(serializer, "serializer") instanceof Generated);
    assertTrue(ReflectionUtils.getObjectFieldValue(serializer, "serializer1") instanceof Generated);

    assertRoundTrip(fory, value);
  }

  private static void assertRoundTrip(Fory fory, ContainerPayload value) {
    ContainerPayload roundTrip =
        (ContainerPayload) fory.deserialize(fory.serialize(value));
    assertEquals(roundTrip.nestedPayload.id, value.nestedPayload.id);
    assertEquals(roundTrip.nestedPayload.name, value.nestedPayload.name);
    assertEquals(roundTrip.details.category, value.details.category);
    assertEquals(roundTrip.details.enabled, value.details.enabled);
  }

  public static final class ContainerPayload {
    public NestedPayload nestedPayload;
    public PayloadDetails details;

    public ContainerPayload() {}

    public ContainerPayload(NestedPayload nestedPayload, PayloadDetails details) {
      this.nestedPayload = nestedPayload;
      this.details = details;
    }
  }

  public static final class NestedPayload {
    public int id;
    public String name;

    public NestedPayload() {}

    public NestedPayload(int id, String name) {
      this.id = id;
      this.name = name;
    }
  }

  public static final class PayloadDetails {
    public String category;
    public boolean enabled;

    public PayloadDetails() {}

    public PayloadDetails(String category, boolean enabled) {
      this.category = category;
      this.enabled = enabled;
    }
  }
}

Run the test with debug output enabled:

ENABLE_FORY_DEBUG_OUTPUT=1 mvn -pl fory-core -am \
  -Dtest=org.apache.fory.builder.ForyHiddenSerializerFieldTest \
  -Dsurefire.failIfNoSpecifiedTests=false test

This matches the observed failure shape: Fory first creates a serializer for the parent object, then async-compiles serializers for nested field types. When a nested serializer JIT completes, Generated.GeneratedSerializer#registerJITNotifyCallback updates the parent generated serializer's Serializer field through ReflectionUtils.setObjectFieldValue.

What did you expect to see?

Adding an allow-all TypeChecker should not change serialization behavior compared with the implicit allow-all behavior when class registration is disabled. Hidden generated serializer classes should be usable by the async serializer JIT callback path.

What did you see instead?

The parent object itself is not hidden. The failure happens when Fory updates a Serializer field declared by the hidden generated serializer class. The base Unsafe-backed field accessor calls Unsafe.objectFieldOffset for that field, which the JDK rejects:

java.lang.UnsupportedOperationException: can't get field offset on a hidden class:
org.apache.fory.serializer.Serializer
...ContainerPayloadForyCodec_0/0x000000....serializer1
  at sun.misc.Unsafe.objectFieldOffset
  at org.apache.fory.reflect.InstanceFieldAccessors$InstanceAccessor.fieldOffset
  at org.apache.fory.reflect.ReflectionUtils.setObjectFieldValue
  at org.apache.fory.builder.Generated$GeneratedSerializer$1.onNotifyResult
  at org.apache.fory.builder.JITContext.lambda$registerSerializerJITCallback$0

The original failure can then cascade into a secondary null-list failure in JITContext callback bookkeeping:

java.lang.NullPointerException: Cannot invoke "java.util.List.iterator()" because the return value of "java.util.Map.get(Object)" is null
  at org.apache.fory.builder.JITContext.lambda$registerSerializerJITCallback$0

Anything Else?

Disabling async compilation avoids this callback path and is a practical workaround. The runtime should still avoid Unsafe offsets for hidden-class fields and should handle callback failures without cascading.

Are you willing to submit a PR?

  • I'm willing to submit a PR!

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions