From 26f80299ee8d1d73fb0f01d3f3b23a3910c7a7ab Mon Sep 17 00:00:00 2001 From: Tatu Saloranta Date: Mon, 13 Apr 2026 15:21:26 -0700 Subject: [PATCH 1/8] Add "afterburner-tests"; enhanced Afterburner-for-classpath tests --- afterburner-tests/pom.xml | 107 ++++++++++++ .../AfterburnerInjectionTestBase.java | 155 ++++++++++++++++++ .../java/classpath/CreatorOptimizerTest.java | 73 +++++++++ .../java/classpath/InjectionSmokeTest.java | 60 +++++++ .../classpath/MutatorSpecializationsTest.java | 117 +++++++++++++ pom.xml | 2 + release-notes/VERSION | 1 + 7 files changed, 515 insertions(+) create mode 100644 afterburner-tests/pom.xml create mode 100644 afterburner-tests/src/test/java/classpath/AfterburnerInjectionTestBase.java create mode 100644 afterburner-tests/src/test/java/classpath/CreatorOptimizerTest.java create mode 100644 afterburner-tests/src/test/java/classpath/InjectionSmokeTest.java create mode 100644 afterburner-tests/src/test/java/classpath/MutatorSpecializationsTest.java diff --git a/afterburner-tests/pom.xml b/afterburner-tests/pom.xml new file mode 100644 index 00000000..05c8f514 --- /dev/null +++ b/afterburner-tests/pom.xml @@ -0,0 +1,107 @@ + + 4.0.0 + + tools.jackson.module + jackson-modules-base + 3.2.0-SNAPSHOT + + + jackson-module-afterburner-tests + Jackson module: Afterburner integration tests + jar + + Classpath-mode integration tests for jackson-module-afterburner. This module +intentionally has no module-info.java so that its test POJOs live in the unnamed module, where +their packages are not sealed-by-module. That lets Afterburner exercise its bytecode injection +paths (PropertyMutatorCollector, CreatorOptimizer, PropertyAccessorCollector) — paths that are +gated off when the target bean sits in a named JPMS module. This module is test-only and is +never published. + + + + tools.jackson.module + jackson-module-afterburner + ${project.version} + + + + + + + + org.apache.maven.plugins + maven-jar-plugin + + + default-jar + none + + + + + org.apache.maven.plugins + maven-install-plugin + + true + + + + org.apache.maven.plugins + maven-deploy-plugin + + true + + + + + org.apache.felix + maven-bundle-plugin + + + bundle-manifest + none + + + default-install + none + + + + + org.cyclonedx + cyclonedx-maven-plugin + + + default + none + + + + + org.gradlex + gradle-module-metadata-maven-plugin + + + default + none + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + false + + + + + diff --git a/afterburner-tests/src/test/java/classpath/AfterburnerInjectionTestBase.java b/afterburner-tests/src/test/java/classpath/AfterburnerInjectionTestBase.java new file mode 100644 index 00000000..913a0934 --- /dev/null +++ b/afterburner-tests/src/test/java/classpath/AfterburnerInjectionTestBase.java @@ -0,0 +1,155 @@ +package classpath; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +import tools.jackson.databind.BeanDescription; +import tools.jackson.databind.DeserializationConfig; +import tools.jackson.databind.SerializationConfig; +import tools.jackson.databind.ValueDeserializer; +import tools.jackson.databind.ValueSerializer; +import tools.jackson.databind.deser.SettableBeanProperty; +import tools.jackson.databind.deser.ValueDeserializerModifier; +import tools.jackson.databind.deser.bean.BeanDeserializer; +import tools.jackson.databind.json.JsonMapper; +import tools.jackson.databind.module.SimpleModule; +import tools.jackson.databind.ser.BeanPropertyWriter; +import tools.jackson.databind.ser.ValueSerializerModifier; +import tools.jackson.module.afterburner.AfterburnerModule; + +import static org.junit.jupiter.api.Assertions.assertNotNull; + +// Test utilities for verifying that Afterburner's bytecode injection pipeline actually +// ran on a given POJO. Because OptimizedSettableBeanProperty and its serializer-side +// counterpart are package-private inside afterburner, these checks rely on reflection +// and simple-name matching rather than compile-time type references. +abstract class AfterburnerInjectionTestBase +{ + /** Builds a mapper with AfterburnerModule + a capture hook that remembers + * the built deserializer and serializer for each bean class we touch. */ + protected static Harness newHarness() { + return new Harness(); + } + + protected static final class Harness { + private final ConcurrentMap, ValueDeserializer> desers = new ConcurrentHashMap<>(); + private final ConcurrentMap, ValueSerializer> sers = new ConcurrentHashMap<>(); + final JsonMapper mapper; + + Harness() { + SimpleModule capture = new SimpleModule("capture") { + private static final long serialVersionUID = 1L; + @Override + public void setupModule(SetupContext ctxt) { + super.setupModule(ctxt); + ctxt.addDeserializerModifier(new ValueDeserializerModifier() { + private static final long serialVersionUID = 1L; + @Override + public ValueDeserializer modifyDeserializer( + DeserializationConfig cfg, BeanDescription.Supplier ref, + ValueDeserializer d) { + desers.put(ref.getBeanClass(), d); + return d; + } + }); + ctxt.addSerializerModifier(new ValueSerializerModifier() { + private static final long serialVersionUID = 1L; + @Override + public ValueSerializer modifySerializer( + SerializationConfig cfg, BeanDescription.Supplier ref, + ValueSerializer s) { + sers.put(ref.getBeanClass(), s); + return s; + } + }); + } + }; + this.mapper = JsonMapper.builder() + .addModule(new AfterburnerModule()) + .addModule(capture) + .build(); + } + + ValueDeserializer deserFor(Class cls) { + ValueDeserializer d = desers.get(cls); + assertNotNull(d, "no deserializer captured for " + cls.getName()); + return d; + } + + ValueSerializer serFor(Class cls) { + ValueSerializer s = sers.get(cls); + assertNotNull(s, "no serializer captured for " + cls.getName()); + return s; + } + } + + /** Returns the `_propsByIndex` array from a bean deserializer, walking up the + * class hierarchy because the field is declared on a base class. */ + protected static SettableBeanProperty[] propsOf(ValueDeserializer deser) { + if (!(deser instanceof BeanDeserializer)) { + throw new AssertionError("not a BeanDeserializer: " + deser.getClass().getName()); + } + Class c = deser.getClass(); + while (c != null) { + try { + Field f = c.getDeclaredField("_propsByIndex"); + f.setAccessible(true); + return (SettableBeanProperty[]) f.get(deser); + } catch (NoSuchFieldException ignore) { + c = c.getSuperclass(); + } catch (IllegalAccessException e) { + throw new AssertionError(e); + } + } + throw new AssertionError("_propsByIndex not found on " + deser.getClass()); + } + + /** Returns the BeanPropertyWriter[] from a bean serializer, walking up. */ + protected static List writersOf(ValueSerializer ser) { + Class c = ser.getClass(); + while (c != null) { + try { + Field f = c.getDeclaredField("_props"); + f.setAccessible(true); + BeanPropertyWriter[] arr = (BeanPropertyWriter[]) f.get(ser); + List out = new ArrayList<>(arr.length); + for (BeanPropertyWriter w : arr) out.add(w); + return out; + } catch (NoSuchFieldException ignore) { + c = c.getSuperclass(); + } catch (IllegalAccessException e) { + throw new AssertionError(e); + } + } + throw new AssertionError("_props not found on " + ser.getClass()); + } + + /** True if `prop`'s class chain contains Afterburner's OptimizedSettableBeanProperty. */ + protected static boolean isOptimizedProperty(SettableBeanProperty prop) { + Class c = prop.getClass(); + while (c != null) { + if ("OptimizedSettableBeanProperty".equals(c.getSimpleName()) + && c.getPackageName().startsWith("tools.jackson.module.afterburner")) { + return true; + } + c = c.getSuperclass(); + } + return false; + } + + /** True if `writer`'s class chain contains Afterburner's OptimizedBeanPropertyWriter. */ + protected static boolean isOptimizedWriter(BeanPropertyWriter writer) { + Class c = writer.getClass(); + while (c != null) { + if ("OptimizedBeanPropertyWriter".equals(c.getSimpleName()) + && c.getPackageName().startsWith("tools.jackson.module.afterburner")) { + return true; + } + c = c.getSuperclass(); + } + return false; + } +} diff --git a/afterburner-tests/src/test/java/classpath/CreatorOptimizerTest.java b/afterburner-tests/src/test/java/classpath/CreatorOptimizerTest.java new file mode 100644 index 00000000..e5955fb5 --- /dev/null +++ b/afterburner-tests/src/test/java/classpath/CreatorOptimizerTest.java @@ -0,0 +1,73 @@ +package classpath; + +import org.junit.jupiter.api.Test; + +import tools.jackson.databind.deser.ValueInstantiator; +import tools.jackson.databind.deser.bean.BeanDeserializer; + +import java.lang.reflect.Field; + +import static org.junit.jupiter.api.Assertions.*; + +// Verifies CreatorOptimizer kicks in for a POJO that uses the default constructor: +// the deserializer's ValueInstantiator should be replaced with an Afterburner-generated +// subclass of StdValueInstantiator, not the plain StdValueInstantiator itself. If +// CreatorOptimizer's ByteBuddy codegen or its "install on the builder" wiring breaks, +// this test surfaces it. +public class CreatorOptimizerTest extends AfterburnerInjectionTestBase +{ + public static class DefaultCtorBean { + public int a; + public String b; + } + + @Test + public void testCreatorReplacedForDefaultCtor() throws Exception + { + Harness h = newHarness(); + DefaultCtorBean bean = h.mapper.readValue("{\"a\":1,\"b\":\"hi\"}", DefaultCtorBean.class); + assertEquals(1, bean.a); + assertEquals("hi", bean.b); + + BeanDeserializer bd = (BeanDeserializer) h.deserFor(DefaultCtorBean.class); + + // Read _valueInstantiator from BeanDeserializerBase via reflection. + ValueInstantiator inst = readValueInstantiator(bd); + assertNotNull(inst); + + // Afterburner replaces the plain StdValueInstantiator with a bytecode-generated + // subclass whose class-chain includes OptimizedValueInstantiator (the abstract + // base CreatorOptimizer produces subclasses of) and whose simple name contains + // "Creator4JacksonDeserializer". + String genClassName = inst.getClass().getName(); + assertTrue(inst.getClass().getSimpleName().contains("Creator4JacksonDeserializer"), + "CreatorOptimizer did not replace the ValueInstantiator — still " + + genClassName); + assertTrue(classChainIncludes(inst.getClass(), "OptimizedValueInstantiator"), + "generated creator should extend OptimizedValueInstantiator; got " + + genClassName); + } + + private static boolean classChainIncludes(Class cls, String simpleName) { + Class c = cls; + while (c != null) { + if (simpleName.equals(c.getSimpleName())) return true; + c = c.getSuperclass(); + } + return false; + } + + private static ValueInstantiator readValueInstantiator(BeanDeserializer bd) throws Exception { + Class c = bd.getClass(); + while (c != null) { + try { + Field f = c.getDeclaredField("_valueInstantiator"); + f.setAccessible(true); + return (ValueInstantiator) f.get(bd); + } catch (NoSuchFieldException ignore) { + c = c.getSuperclass(); + } + } + throw new AssertionError("_valueInstantiator not found"); + } +} diff --git a/afterburner-tests/src/test/java/classpath/InjectionSmokeTest.java b/afterburner-tests/src/test/java/classpath/InjectionSmokeTest.java new file mode 100644 index 00000000..37307e5c --- /dev/null +++ b/afterburner-tests/src/test/java/classpath/InjectionSmokeTest.java @@ -0,0 +1,60 @@ +package classpath; + +import org.junit.jupiter.api.Test; + +import tools.jackson.databind.deser.SettableBeanProperty; + +import static org.junit.jupiter.api.Assertions.*; + +// High-level smoke test: serialize+deserialize a simple POJO with a mix of primitive +// and reference fields, and confirm that every property on both sides was replaced +// with an Afterburner-generated optimized variant. This transitively exercises +// MyClassLoader.defineClass, PropertyMutatorCollector / PropertyAccessorCollector, +// and the OptimizedSettableBeanProperty / OptimizedBeanPropertyWriter swap in +// ABDeserializerModifier / ABSerializerModifier. +public class InjectionSmokeTest extends AfterburnerInjectionTestBase +{ + public static class SimplePojo { + public int intField; + public long longField; + public boolean boolField; + public String stringField; + + public SimplePojo() { } + + public SimplePojo(int i, long l, boolean b, String s) { + intField = i; longField = l; boolField = b; stringField = s; + } + } + + @Test + public void testRoundTripWithFullInjection() throws Exception + { + Harness h = newHarness(); + String json = h.mapper.writeValueAsString(new SimplePojo(1, 2L, true, "x")); + SimplePojo back = h.mapper.readValue(json, SimplePojo.class); + + assertEquals(1, back.intField); + assertEquals(2L, back.longField); + assertTrue(back.boolField); + assertEquals("x", back.stringField); + + // Every property on the deserializer side should be Afterburner-optimized. + SettableBeanProperty[] props = propsOf(h.deserFor(SimplePojo.class)); + assertEquals(4, props.length); + for (SettableBeanProperty prop : props) { + assertTrue(isOptimizedProperty(prop), + "deser property '" + prop.getName() + "' not optimized (is " + + prop.getClass().getName() + "); injection pipeline did not run"); + } + + // Every writer on the serializer side should be Afterburner-optimized. + var writers = writersOf(h.serFor(SimplePojo.class)); + assertEquals(4, writers.size()); + for (var w : writers) { + assertTrue(isOptimizedWriter(w), + "ser writer '" + w.getName() + "' not optimized (is " + + w.getClass().getName() + "); injection pipeline did not run"); + } + } +} diff --git a/afterburner-tests/src/test/java/classpath/MutatorSpecializationsTest.java b/afterburner-tests/src/test/java/classpath/MutatorSpecializationsTest.java new file mode 100644 index 00000000..534adabc --- /dev/null +++ b/afterburner-tests/src/test/java/classpath/MutatorSpecializationsTest.java @@ -0,0 +1,117 @@ +package classpath; + +import org.junit.jupiter.api.Test; + +import tools.jackson.databind.deser.SettableBeanProperty; + +import static org.junit.jupiter.api.Assertions.*; + +// Verifies each of PropertyMutatorCollector's primitive-type specializations — int, +// long, boolean, and object/String — runs end-to-end for both public-field access +// and setter-method access. A regression in any individual code-generation path +// (e.g. a bad ByteBuddy template, a wrong descriptor, a missing bytecode branch) +// should surface here. +public class MutatorSpecializationsTest extends AfterburnerInjectionTestBase +{ + public static class IntFieldBean { + public int value; + } + public static class LongFieldBean { + public long value; + } + public static class BooleanFieldBean { + public boolean value; + } + public static class StringFieldBean { + public String value; + } + + public static class IntSetterBean { + private int value; + public int getValue() { return value; } + public void setValue(int v) { this.value = v; } + } + public static class LongSetterBean { + private long value; + public long getValue() { return value; } + public void setValue(long v) { this.value = v; } + } + public static class BooleanSetterBean { + private boolean value; + public boolean isValue() { return value; } + public void setValue(boolean v) { this.value = v; } + } + public static class StringSetterBean { + private String value; + public String getValue() { return value; } + public void setValue(String v) { this.value = v; } + } + + private final Harness h = newHarness(); + + @Test + public void testIntField() throws Exception { + IntFieldBean b = h.mapper.readValue("{\"value\":42}", IntFieldBean.class); + assertEquals(42, b.value); + assertAllPropsOptimized(IntFieldBean.class); + } + + @Test + public void testLongField() throws Exception { + LongFieldBean b = h.mapper.readValue("{\"value\":9999999999}", LongFieldBean.class); + assertEquals(9999999999L, b.value); + assertAllPropsOptimized(LongFieldBean.class); + } + + @Test + public void testBooleanField() throws Exception { + BooleanFieldBean b = h.mapper.readValue("{\"value\":true}", BooleanFieldBean.class); + assertTrue(b.value); + assertAllPropsOptimized(BooleanFieldBean.class); + } + + @Test + public void testStringField() throws Exception { + StringFieldBean b = h.mapper.readValue("{\"value\":\"hi\"}", StringFieldBean.class); + assertEquals("hi", b.value); + assertAllPropsOptimized(StringFieldBean.class); + } + + @Test + public void testIntSetter() throws Exception { + IntSetterBean b = h.mapper.readValue("{\"value\":42}", IntSetterBean.class); + assertEquals(42, b.getValue()); + assertAllPropsOptimized(IntSetterBean.class); + } + + @Test + public void testLongSetter() throws Exception { + LongSetterBean b = h.mapper.readValue("{\"value\":9999999999}", LongSetterBean.class); + assertEquals(9999999999L, b.getValue()); + assertAllPropsOptimized(LongSetterBean.class); + } + + @Test + public void testBooleanSetter() throws Exception { + BooleanSetterBean b = h.mapper.readValue("{\"value\":true}", BooleanSetterBean.class); + assertTrue(b.isValue()); + assertAllPropsOptimized(BooleanSetterBean.class); + } + + @Test + public void testStringSetter() throws Exception { + StringSetterBean b = h.mapper.readValue("{\"value\":\"hi\"}", StringSetterBean.class); + assertEquals("hi", b.getValue()); + assertAllPropsOptimized(StringSetterBean.class); + } + + private void assertAllPropsOptimized(Class cls) { + SettableBeanProperty[] props = propsOf(h.deserFor(cls)); + assertTrue(props.length > 0, "no properties for " + cls.getSimpleName()); + for (SettableBeanProperty p : props) { + assertTrue(isOptimizedProperty(p), + cls.getSimpleName() + "." + p.getName() + " not optimized: " + + p.getClass().getName()); + } + } +} diff --git a/pom.xml b/pom.xml index 5de73cbd..ea8ef52d 100644 --- a/pom.xml +++ b/pom.xml @@ -24,6 +24,8 @@ not datatype, data format, or JAX-RS provider modules. afterburner + + afterburner-tests android-record blackbird guice diff --git a/release-notes/VERSION b/release-notes/VERSION index 9691ec62..fbb560b2 100644 --- a/release-notes/VERSION +++ b/release-notes/VERSION @@ -30,6 +30,7 @@ Active maintainers: (fix by @cowtowncoder, w/ Claude code) #343: Improve Afterburner testing, access checks (fix by @cowtowncoder, w/ Claude code) +- Added module "afterburner-tests" for better Afterburner-in-classpath testing - Removed project Android SDK level overrides, use defaults (ASDK 34) (affects `android-records` module's verification; was validating ASDK 26) From 551e097ae42196849bb53b4edec28282da531671 Mon Sep 17 00:00:00 2001 From: Tatu Saloranta Date: Mon, 13 Apr 2026 15:38:48 -0700 Subject: [PATCH 2/8] Test rename --- .../afterburner/inject}/AfterburnerInjectionTestBase.java | 2 +- .../module/afterburner/inject}/CreatorOptimizerTest.java | 2 +- .../jackson/module/afterburner/inject}/InjectionSmokeTest.java | 2 +- .../module/afterburner/inject}/MutatorSpecializationsTest.java | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) rename afterburner-tests/src/test/java/{classpath => tools/jackson/module/afterburner/inject}/AfterburnerInjectionTestBase.java (99%) rename afterburner-tests/src/test/java/{classpath => tools/jackson/module/afterburner/inject}/CreatorOptimizerTest.java (98%) rename afterburner-tests/src/test/java/{classpath => tools/jackson/module/afterburner/inject}/InjectionSmokeTest.java (98%) rename afterburner-tests/src/test/java/{classpath => tools/jackson/module/afterburner/inject}/MutatorSpecializationsTest.java (98%) diff --git a/afterburner-tests/src/test/java/classpath/AfterburnerInjectionTestBase.java b/afterburner-tests/src/test/java/tools/jackson/module/afterburner/inject/AfterburnerInjectionTestBase.java similarity index 99% rename from afterburner-tests/src/test/java/classpath/AfterburnerInjectionTestBase.java rename to afterburner-tests/src/test/java/tools/jackson/module/afterburner/inject/AfterburnerInjectionTestBase.java index 913a0934..6adb963e 100644 --- a/afterburner-tests/src/test/java/classpath/AfterburnerInjectionTestBase.java +++ b/afterburner-tests/src/test/java/tools/jackson/module/afterburner/inject/AfterburnerInjectionTestBase.java @@ -1,4 +1,4 @@ -package classpath; +package tools.jackson.module.afterburner.inject; import java.lang.reflect.Field; import java.util.ArrayList; diff --git a/afterburner-tests/src/test/java/classpath/CreatorOptimizerTest.java b/afterburner-tests/src/test/java/tools/jackson/module/afterburner/inject/CreatorOptimizerTest.java similarity index 98% rename from afterburner-tests/src/test/java/classpath/CreatorOptimizerTest.java rename to afterburner-tests/src/test/java/tools/jackson/module/afterburner/inject/CreatorOptimizerTest.java index e5955fb5..9fcc7716 100644 --- a/afterburner-tests/src/test/java/classpath/CreatorOptimizerTest.java +++ b/afterburner-tests/src/test/java/tools/jackson/module/afterburner/inject/CreatorOptimizerTest.java @@ -1,4 +1,4 @@ -package classpath; +package tools.jackson.module.afterburner.inject; import org.junit.jupiter.api.Test; diff --git a/afterburner-tests/src/test/java/classpath/InjectionSmokeTest.java b/afterburner-tests/src/test/java/tools/jackson/module/afterburner/inject/InjectionSmokeTest.java similarity index 98% rename from afterburner-tests/src/test/java/classpath/InjectionSmokeTest.java rename to afterburner-tests/src/test/java/tools/jackson/module/afterburner/inject/InjectionSmokeTest.java index 37307e5c..29fbb441 100644 --- a/afterburner-tests/src/test/java/classpath/InjectionSmokeTest.java +++ b/afterburner-tests/src/test/java/tools/jackson/module/afterburner/inject/InjectionSmokeTest.java @@ -1,4 +1,4 @@ -package classpath; +package tools.jackson.module.afterburner.inject; import org.junit.jupiter.api.Test; diff --git a/afterburner-tests/src/test/java/classpath/MutatorSpecializationsTest.java b/afterburner-tests/src/test/java/tools/jackson/module/afterburner/inject/MutatorSpecializationsTest.java similarity index 98% rename from afterburner-tests/src/test/java/classpath/MutatorSpecializationsTest.java rename to afterburner-tests/src/test/java/tools/jackson/module/afterburner/inject/MutatorSpecializationsTest.java index 534adabc..391f0779 100644 --- a/afterburner-tests/src/test/java/classpath/MutatorSpecializationsTest.java +++ b/afterburner-tests/src/test/java/tools/jackson/module/afterburner/inject/MutatorSpecializationsTest.java @@ -1,4 +1,4 @@ -package classpath; +package tools.jackson.module.afterburner.inject; import org.junit.jupiter.api.Test; From cf6e0de6635da143b1829b1a0e236f032bd4c861 Mon Sep 17 00:00:00 2001 From: Tatu Saloranta Date: Mon, 13 Apr 2026 15:50:21 -0700 Subject: [PATCH 3/8] MOre clean up --- afterburner-tests/pom.xml | 33 ++++++++++++------- .../inject/CreatorOptimizerTest.java | 14 +++----- 2 files changed, 26 insertions(+), 21 deletions(-) diff --git a/afterburner-tests/pom.xml b/afterburner-tests/pom.xml index 05c8f514..a420b3e7 100644 --- a/afterburner-tests/pom.xml +++ b/afterburner-tests/pom.xml @@ -28,9 +28,15 @@ never published. + Packaging stays `jar` so the compile + test lifecycle runs normally, + but every artifact-producing / artifact-publishing plugin inherited + from the parent chain is unbound here. + + Verified via `mvn help:effective-pom -pl afterburner-tests`: every + execution listed below appears in the merged model at phase=none or + with skip=true. If you rename an execution here, re-verify with that + command — a typo turns into silent dead config and stale artifacts + in target/ rather than a build error. --> org.apache.maven.plugins maven-jar-plugin @@ -55,9 +61,8 @@ never published. true - + org.apache.felix maven-bundle-plugin @@ -72,16 +77,20 @@ never published. + org.cyclonedx cyclonedx-maven-plugin - - - default - none - - + + true + + org.gradlex gradle-module-metadata-maven-plugin diff --git a/afterburner-tests/src/test/java/tools/jackson/module/afterburner/inject/CreatorOptimizerTest.java b/afterburner-tests/src/test/java/tools/jackson/module/afterburner/inject/CreatorOptimizerTest.java index 9fcc7716..d7cdf440 100644 --- a/afterburner-tests/src/test/java/tools/jackson/module/afterburner/inject/CreatorOptimizerTest.java +++ b/afterburner-tests/src/test/java/tools/jackson/module/afterburner/inject/CreatorOptimizerTest.java @@ -36,16 +36,12 @@ public void testCreatorReplacedForDefaultCtor() throws Exception assertNotNull(inst); // Afterburner replaces the plain StdValueInstantiator with a bytecode-generated - // subclass whose class-chain includes OptimizedValueInstantiator (the abstract - // base CreatorOptimizer produces subclasses of) and whose simple name contains - // "Creator4JacksonDeserializer". - String genClassName = inst.getClass().getName(); - assertTrue(inst.getClass().getSimpleName().contains("Creator4JacksonDeserializer"), - "CreatorOptimizer did not replace the ValueInstantiator — still " - + genClassName); + // subclass whose class-chain includes OptimizedValueInstantiator — the abstract + // base CreatorOptimizer produces subclasses of. Checking for that base in the + // chain is sufficient: it can only appear if CreatorOptimizer actually ran. assertTrue(classChainIncludes(inst.getClass(), "OptimizedValueInstantiator"), - "generated creator should extend OptimizedValueInstantiator; got " - + genClassName); + "CreatorOptimizer did not replace the ValueInstantiator; got " + + inst.getClass().getName()); } private static boolean classChainIncludes(Class cls, String simpleName) { From fd3d1359eacf9d09d7f36f0d9c3b9c52d71c68e1 Mon Sep 17 00:00:00 2001 From: Tatu Saloranta Date: Mon, 13 Apr 2026 16:08:30 -0700 Subject: [PATCH 4/8] Test clean up --- afterburner-tests/pom.xml | 14 +++ .../inject/AfterburnerInjectionTestBase.java | 51 ++++---- .../inject/CreatorOptimizerTest.java | 119 +++++++++++++----- .../inject/InjectionSmokeTest.java | 60 --------- .../inject/SerializerInjectionTest.java | 54 ++++++++ 5 files changed, 183 insertions(+), 115 deletions(-) delete mode 100644 afterburner-tests/src/test/java/tools/jackson/module/afterburner/inject/InjectionSmokeTest.java create mode 100644 afterburner-tests/src/test/java/tools/jackson/module/afterburner/inject/SerializerInjectionTest.java diff --git a/afterburner-tests/pom.xml b/afterburner-tests/pom.xml index a420b3e7..e434af1b 100644 --- a/afterburner-tests/pom.xml +++ b/afterburner-tests/pom.xml @@ -101,6 +101,20 @@ never published. + + + org.jacoco + jacoco-maven-plugin + + + report + none + + + diff --git a/afterburner-tests/src/test/java/tools/jackson/module/afterburner/inject/AfterburnerInjectionTestBase.java b/afterburner-tests/src/test/java/tools/jackson/module/afterburner/inject/AfterburnerInjectionTestBase.java index 6adb963e..e970500d 100644 --- a/afterburner-tests/src/test/java/tools/jackson/module/afterburner/inject/AfterburnerInjectionTestBase.java +++ b/afterburner-tests/src/test/java/tools/jackson/module/afterburner/inject/AfterburnerInjectionTestBase.java @@ -86,45 +86,48 @@ ValueSerializer serFor(Class cls) { } } - /** Returns the `_propsByIndex` array from a bean deserializer, walking up the - * class hierarchy because the field is declared on a base class. */ + /** Returns the `_propsByIndex` array from a bean deserializer. */ protected static SettableBeanProperty[] propsOf(ValueDeserializer deser) { if (!(deser instanceof BeanDeserializer)) { throw new AssertionError("not a BeanDeserializer: " + deser.getClass().getName()); } - Class c = deser.getClass(); - while (c != null) { - try { - Field f = c.getDeclaredField("_propsByIndex"); - f.setAccessible(true); - return (SettableBeanProperty[]) f.get(deser); - } catch (NoSuchFieldException ignore) { - c = c.getSuperclass(); - } catch (IllegalAccessException e) { - throw new AssertionError(e); - } - } - throw new AssertionError("_propsByIndex not found on " + deser.getClass()); + return (SettableBeanProperty[]) reflectField(deser, "_propsByIndex"); } - /** Returns the BeanPropertyWriter[] from a bean serializer, walking up. */ + /** Returns the BeanPropertyWriter[] from a bean serializer, as a list. */ protected static List writersOf(ValueSerializer ser) { - Class c = ser.getClass(); + BeanPropertyWriter[] arr = (BeanPropertyWriter[]) reflectField(ser, "_props"); + List out = new ArrayList<>(arr.length); + for (BeanPropertyWriter w : arr) { + out.add(w); + } + return out; + } + + /** Walks the class chain of {@code instance} looking for a declared field + * named {@code fieldName}, sets it accessible, and returns its value. + * Throws a descriptive AssertionError if the field isn't found — on the + * assumption that a missing field usually means databind renamed/removed + * something, not a bug in the test. */ + protected static Object reflectField(Object instance, String fieldName) { + Class c = instance.getClass(); while (c != null) { try { - Field f = c.getDeclaredField("_props"); + Field f = c.getDeclaredField(fieldName); f.setAccessible(true); - BeanPropertyWriter[] arr = (BeanPropertyWriter[]) f.get(ser); - List out = new ArrayList<>(arr.length); - for (BeanPropertyWriter w : arr) out.add(w); - return out; + return f.get(instance); } catch (NoSuchFieldException ignore) { c = c.getSuperclass(); } catch (IllegalAccessException e) { - throw new AssertionError(e); + throw new AssertionError("cannot read field '" + fieldName + "' on " + + instance.getClass().getName(), e); } } - throw new AssertionError("_props not found on " + ser.getClass()); + throw new AssertionError("expected databind field '" + fieldName + "' on " + + instance.getClass().getName() + + " (walked up full class chain) — databind may have renamed or" + + " removed it; update " + AfterburnerInjectionTestBase.class.getSimpleName() + + " to match."); } /** True if `prop`'s class chain contains Afterburner's OptimizedSettableBeanProperty. */ diff --git a/afterburner-tests/src/test/java/tools/jackson/module/afterburner/inject/CreatorOptimizerTest.java b/afterburner-tests/src/test/java/tools/jackson/module/afterburner/inject/CreatorOptimizerTest.java index d7cdf440..cac13ea4 100644 --- a/afterburner-tests/src/test/java/tools/jackson/module/afterburner/inject/CreatorOptimizerTest.java +++ b/afterburner-tests/src/test/java/tools/jackson/module/afterburner/inject/CreatorOptimizerTest.java @@ -2,18 +2,27 @@ import org.junit.jupiter.api.Test; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + import tools.jackson.databind.deser.ValueInstantiator; import tools.jackson.databind.deser.bean.BeanDeserializer; - -import java.lang.reflect.Field; +import tools.jackson.databind.deser.std.StdValueInstantiator; import static org.junit.jupiter.api.Assertions.*; -// Verifies CreatorOptimizer kicks in for a POJO that uses the default constructor: -// the deserializer's ValueInstantiator should be replaced with an Afterburner-generated -// subclass of StdValueInstantiator, not the plain StdValueInstantiator itself. If -// CreatorOptimizer's ByteBuddy codegen or its "install on the builder" wiring breaks, -// this test surfaces it. +// Covers CreatorOptimizer's main branches: +// +// 1. Default public constructor -> creator replaced with generated subclass. +// 2. Public static factory method -> creator replaced with generated subclass +// (the same generated-bytecode path, different stack manipulation). +// 3. Property-based @JsonCreator -> CreatorOptimizer bails out +// (canCreateFromObjectWith() is true); the plain StdValueInstantiator stays. +// +// Together these guard against (a) CreatorOptimizer silently breaking for one +// of its two supported paths, and (b) CreatorOptimizer accidentally running on +// an unsupported shape (which would turn into a deserialization failure at +// runtime rather than a clean fallback). public class CreatorOptimizerTest extends AfterburnerInjectionTestBase { public static class DefaultCtorBean { @@ -21,29 +30,91 @@ public static class DefaultCtorBean { public String b; } + public static class FactoryMethodBean { + public int a; + public String b; + + // Private default ctor forces databind to pick up the static factory. + // The @JsonCreator annotation with no args is how databind recognizes a + // no-arg static method as a "default creator" — without the annotation + // databind would fall through to looking for a default constructor. + private FactoryMethodBean() { } + + @JsonCreator + public static FactoryMethodBean create() { + return new FactoryMethodBean(); + } + } + + public static class PropertyCreatorBean { + public final int a; + public final String b; + + @JsonCreator + public PropertyCreatorBean(@JsonProperty("a") int a, @JsonProperty("b") String b) { + this.a = a; + this.b = b; + } + } + + private final Harness h = newHarness(); + @Test public void testCreatorReplacedForDefaultCtor() throws Exception { - Harness h = newHarness(); DefaultCtorBean bean = h.mapper.readValue("{\"a\":1,\"b\":\"hi\"}", DefaultCtorBean.class); assertEquals(1, bean.a); assertEquals("hi", bean.b); - BeanDeserializer bd = (BeanDeserializer) h.deserFor(DefaultCtorBean.class); + ValueInstantiator inst = instantiatorFor(DefaultCtorBean.class); + // OptimizedValueInstantiator is the abstract base CreatorOptimizer produces + // subclasses of — it can only appear in the chain if CreatorOptimizer ran. + assertTrue(classChainIncludes(inst.getClass(), "OptimizedValueInstantiator"), + "CreatorOptimizer did not replace the ValueInstantiator; got " + + inst.getClass().getName()); + } - // Read _valueInstantiator from BeanDeserializerBase via reflection. - ValueInstantiator inst = readValueInstantiator(bd); - assertNotNull(inst); + @Test + public void testCreatorReplacedForStaticFactory() throws Exception + { + FactoryMethodBean bean = h.mapper.readValue("{\"a\":1,\"b\":\"hi\"}", + FactoryMethodBean.class); + assertEquals(1, bean.a); + assertEquals("hi", bean.b); - // Afterburner replaces the plain StdValueInstantiator with a bytecode-generated - // subclass whose class-chain includes OptimizedValueInstantiator — the abstract - // base CreatorOptimizer produces subclasses of. Checking for that base in the - // chain is sufficient: it can only appear if CreatorOptimizer actually ran. + ValueInstantiator inst = instantiatorFor(FactoryMethodBean.class); assertTrue(classChainIncludes(inst.getClass(), "OptimizedValueInstantiator"), - "CreatorOptimizer did not replace the ValueInstantiator; got " + "CreatorOptimizer did not replace the ValueInstantiator for a static" + + " factory creator; got " + inst.getClass().getName()); + } + + @Test + public void testCreatorNotReplacedForPropertyBasedCreator() throws Exception + { + // Deserialization must still work end-to-end, just without CreatorOptimizer + // wrapping (it bails out because canCreateFromObjectWith() is true). + PropertyCreatorBean bean = h.mapper.readValue("{\"a\":1,\"b\":\"hi\"}", + PropertyCreatorBean.class); + assertEquals(1, bean.a); + assertEquals("hi", bean.b); + + ValueInstantiator inst = instantiatorFor(PropertyCreatorBean.class); + assertFalse(classChainIncludes(inst.getClass(), "OptimizedValueInstantiator"), + "CreatorOptimizer should NOT wrap a property-based @JsonCreator POJO," + + " but the generated class " + inst.getClass().getName() + + " extends OptimizedValueInstantiator"); + assertEquals(StdValueInstantiator.class, inst.getClass(), + "fallback should be the plain StdValueInstantiator; got " + inst.getClass().getName()); } + private ValueInstantiator instantiatorFor(Class cls) { + BeanDeserializer bd = (BeanDeserializer) h.deserFor(cls); + ValueInstantiator inst = (ValueInstantiator) reflectField(bd, "_valueInstantiator"); + assertNotNull(inst); + return inst; + } + private static boolean classChainIncludes(Class cls, String simpleName) { Class c = cls; while (c != null) { @@ -52,18 +123,4 @@ private static boolean classChainIncludes(Class cls, String simpleName) { } return false; } - - private static ValueInstantiator readValueInstantiator(BeanDeserializer bd) throws Exception { - Class c = bd.getClass(); - while (c != null) { - try { - Field f = c.getDeclaredField("_valueInstantiator"); - f.setAccessible(true); - return (ValueInstantiator) f.get(bd); - } catch (NoSuchFieldException ignore) { - c = c.getSuperclass(); - } - } - throw new AssertionError("_valueInstantiator not found"); - } } diff --git a/afterburner-tests/src/test/java/tools/jackson/module/afterburner/inject/InjectionSmokeTest.java b/afterburner-tests/src/test/java/tools/jackson/module/afterburner/inject/InjectionSmokeTest.java deleted file mode 100644 index 29fbb441..00000000 --- a/afterburner-tests/src/test/java/tools/jackson/module/afterburner/inject/InjectionSmokeTest.java +++ /dev/null @@ -1,60 +0,0 @@ -package tools.jackson.module.afterburner.inject; - -import org.junit.jupiter.api.Test; - -import tools.jackson.databind.deser.SettableBeanProperty; - -import static org.junit.jupiter.api.Assertions.*; - -// High-level smoke test: serialize+deserialize a simple POJO with a mix of primitive -// and reference fields, and confirm that every property on both sides was replaced -// with an Afterburner-generated optimized variant. This transitively exercises -// MyClassLoader.defineClass, PropertyMutatorCollector / PropertyAccessorCollector, -// and the OptimizedSettableBeanProperty / OptimizedBeanPropertyWriter swap in -// ABDeserializerModifier / ABSerializerModifier. -public class InjectionSmokeTest extends AfterburnerInjectionTestBase -{ - public static class SimplePojo { - public int intField; - public long longField; - public boolean boolField; - public String stringField; - - public SimplePojo() { } - - public SimplePojo(int i, long l, boolean b, String s) { - intField = i; longField = l; boolField = b; stringField = s; - } - } - - @Test - public void testRoundTripWithFullInjection() throws Exception - { - Harness h = newHarness(); - String json = h.mapper.writeValueAsString(new SimplePojo(1, 2L, true, "x")); - SimplePojo back = h.mapper.readValue(json, SimplePojo.class); - - assertEquals(1, back.intField); - assertEquals(2L, back.longField); - assertTrue(back.boolField); - assertEquals("x", back.stringField); - - // Every property on the deserializer side should be Afterburner-optimized. - SettableBeanProperty[] props = propsOf(h.deserFor(SimplePojo.class)); - assertEquals(4, props.length); - for (SettableBeanProperty prop : props) { - assertTrue(isOptimizedProperty(prop), - "deser property '" + prop.getName() + "' not optimized (is " - + prop.getClass().getName() + "); injection pipeline did not run"); - } - - // Every writer on the serializer side should be Afterburner-optimized. - var writers = writersOf(h.serFor(SimplePojo.class)); - assertEquals(4, writers.size()); - for (var w : writers) { - assertTrue(isOptimizedWriter(w), - "ser writer '" + w.getName() + "' not optimized (is " - + w.getClass().getName() + "); injection pipeline did not run"); - } - } -} diff --git a/afterburner-tests/src/test/java/tools/jackson/module/afterburner/inject/SerializerInjectionTest.java b/afterburner-tests/src/test/java/tools/jackson/module/afterburner/inject/SerializerInjectionTest.java new file mode 100644 index 00000000..d7b9afb6 --- /dev/null +++ b/afterburner-tests/src/test/java/tools/jackson/module/afterburner/inject/SerializerInjectionTest.java @@ -0,0 +1,54 @@ +package tools.jackson.module.afterburner.inject; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import tools.jackson.databind.ser.BeanPropertyWriter; + +import static org.junit.jupiter.api.Assertions.*; + +// Verifies Afterburner's serializer-side bytecode injection runs end-to-end: +// PropertyAccessorCollector replaces each BeanPropertyWriter with an +// OptimizedBeanPropertyWriter subclass whose generated accessor reads the field +// directly instead of going through reflection. This is the only test in this +// module that touches the serializer side; deser-side injection is covered by +// MutatorSpecializationsTest. +public class SerializerInjectionTest extends AfterburnerInjectionTestBase +{ + public static class SerPojo { + public int intField; + public long longField; + public boolean boolField; + public String stringField; + + public SerPojo() { } + + public SerPojo(int i, long l, boolean b, String s) { + intField = i; longField = l; boolField = b; stringField = s; + } + } + + @Test + public void testWriterInjectionRuns() throws Exception + { + Harness h = newHarness(); + + // Emit JSON through the optimized serializer and sanity-check the output. + String json = h.mapper.writeValueAsString(new SerPojo(1, 2L, true, "x")); + assertTrue(json.contains("\"intField\":1")); + assertTrue(json.contains("\"longField\":2")); + assertTrue(json.contains("\"boolField\":true")); + assertTrue(json.contains("\"stringField\":\"x\"")); + + // Every writer on the serializer side should be Afterburner-optimized. + List writers = writersOf(h.serFor(SerPojo.class)); + assertEquals(4, writers.size()); + for (BeanPropertyWriter w : writers) { + assertTrue(isOptimizedWriter(w), + "ser writer '" + w.getName() + "' not optimized (is " + + w.getClass().getName() + "); PropertyAccessorCollector" + + " did not run on this POJO"); + } + } +} From 8278b172de587259f3f1d675a8a1ec6a5dab2fc8 Mon Sep 17 00:00:00 2001 From: Tatu Saloranta Date: Mon, 13 Apr 2026 17:23:36 -0700 Subject: [PATCH 5/8] Yet more finishing touches --- afterburner-tests/README.md | 81 ++++++++++++ afterburner-tests/pom.xml | 22 +++- .../inject/AfterburnerInjectionTestBase.java | 27 +++- .../inject/CreatorOptimizerTest.java | 9 -- .../inject/GeneratedClassCachingTest.java | 120 ++++++++++++++++++ 5 files changed, 242 insertions(+), 17 deletions(-) create mode 100644 afterburner-tests/README.md create mode 100644 afterburner-tests/src/test/java/tools/jackson/module/afterburner/inject/GeneratedClassCachingTest.java diff --git a/afterburner-tests/README.md b/afterburner-tests/README.md new file mode 100644 index 00000000..91d9f414 --- /dev/null +++ b/afterburner-tests/README.md @@ -0,0 +1,81 @@ +# jackson-module-afterburner-tests + +Classpath-mode integration tests for `jackson-module-afterburner`. **This module is +test-only and is never published.** + +## Why this module exists + +Afterburner's most valuable optimizations come from bytecode injection: +`PropertyMutatorCollector`, `PropertyAccessorCollector`, and `CreatorOptimizer` +generate custom mutator / accessor / instantiator classes in the *target bean's +package* and install them on the bean's `ClassLoader`. This only works when the +target package is not sealed. + +In Jackson 3.x every module descriptor — including afterburner's own test module +— is a named JPMS module, and the JVM automatically seals every package inside a +named module. As a result, every POJO defined inside `tools.jackson.module.afterburner` +test sources fails the `MyClassLoader.canAddClassInPackageOf` check, and afterburner +silently falls back to plain reflection. That means **none** of afterburner's +injection paths are exercised by the in-tree afterburner test suite — the optimized +code path is effectively dead weight in CI even though the tests appear to pass. + +This module closes that gap. It lives alongside the afterburner module but +deliberately: + +- has **no `module-info.java`** in either main or test sources, +- is configured (`useModulePath=false`) to run its tests on the classpath, +- and therefore loads its test POJOs into the **unnamed module**, whose packages + are not sealed. + +Afterburner's injection pipeline runs on those POJOs normally, and the tests here +verify via reflection on databind internals that each property was actually +replaced with an `OptimizedSettableBeanProperty` / `OptimizedBeanPropertyWriter` +and each default-creator POJO got an `OptimizedValueInstantiator`. + +## Do not add a `module-info.java` here + +Adding a module descriptor would put these test POJOs back into a named module, +re-seal their packages, and silently turn this whole module into dead weight — +same as the in-tree afterburner tests. + +If a future cleanup pass wants to "unify" the project layout by making every +module JPMS-consistent, this one is the exception. Read this file before doing +that. + +## Do not publish this module + +Several parent-POM plugin bindings are unbound or skipped in `pom.xml` so that +`mvn install` on this module produces no jar, no SBOM, no Gradle module metadata, +and no OSGi bundle. The module exists purely to run tests. If you rename an +execution override in `pom.xml`, re-verify with: + +``` +./mvnw help:effective-pom -pl afterburner-tests +``` + +A typo in an execution id turns into silent dead config and leaves stale +artifacts in `target/` rather than a build error. + +## What is covered + +- `PropertyMutatorCollector` — int, long, boolean, and reference-type + specializations for both public-field and setter-method access + (`MutatorSpecializationsTest`). +- `PropertyAccessorCollector` — writer replacement on the serializer side + (`SerializerInjectionTest`). +- `CreatorOptimizer` — default-constructor and static-factory positive cases, + plus property-based `@JsonCreator` negative case (`CreatorOptimizerTest`). +- `MyClassLoader.defineClass` — transitively exercised by all of the above. + +## What is *not* covered + +- Private-class guard (`_classLoader != null && isPrivate(beanClass)`). +- `setUseValueClassLoader(false)` toggle. +- Generated-class caching across multiple POJOs with the same shape. +- Concurrent deserializer construction. +- Fallback path when afterburner correctly refuses to optimize (e.g. a bean + whose package really is sealed — the in-tree afterburner tests inadvertently + cover this). +- GraalVM native-image disable path in `AfterburnerModule.setupModule`. + +See PR #347 for the rationale behind the current coverage and the open follow-ups. diff --git a/afterburner-tests/pom.xml b/afterburner-tests/pom.xml index e434af1b..d6abb965 100644 --- a/afterburner-tests/pom.xml +++ b/afterburner-tests/pom.xml @@ -117,12 +117,32 @@ never published. + packages unsealed. + + The add-opens flag below is required by Afterburner itself, not by + the tests. Afterburner's MyClassLoader reflectively invokes + ClassLoader#findLoadedClass and ClassLoader#defineClass on the bean's + parent classloader so that generated mutator classes are cached on + that parent and reused across mappers. Both methods live in + java.base/java.lang and on Java 17+ are only reachable when the + module is opened to the unnamed module. Without this flag, + Afterburner silently falls back to defining each generated class in + a fresh throwaway MyClassLoader, which leaks classloaders under load + (and which GeneratedClassCachingTest catches as a regression). + + Note: reflection into databind internals from the tests themselves + works without opens because this module runs on the classpath, where + setAccessible(true) from the unnamed module is unrestricted. + + Note 2: surefire's argLine is additive when jacoco's prepare-agent + already set a value, so we reference @{argLine} to preserve the + jacoco agent config. --> org.apache.maven.plugins maven-surefire-plugin false + @{argLine} --add-opens java.base/java.lang=ALL-UNNAMED diff --git a/afterburner-tests/src/test/java/tools/jackson/module/afterburner/inject/AfterburnerInjectionTestBase.java b/afterburner-tests/src/test/java/tools/jackson/module/afterburner/inject/AfterburnerInjectionTestBase.java index e970500d..05ff4abe 100644 --- a/afterburner-tests/src/test/java/tools/jackson/module/afterburner/inject/AfterburnerInjectionTestBase.java +++ b/afterburner-tests/src/test/java/tools/jackson/module/afterburner/inject/AfterburnerInjectionTestBase.java @@ -132,10 +132,21 @@ protected static Object reflectField(Object instance, String fieldName) { /** True if `prop`'s class chain contains Afterburner's OptimizedSettableBeanProperty. */ protected static boolean isOptimizedProperty(SettableBeanProperty prop) { - Class c = prop.getClass(); + return afterburnerClassChainIncludes(prop.getClass(), "OptimizedSettableBeanProperty"); + } + + /** True if `writer`'s class chain contains Afterburner's OptimizedBeanPropertyWriter. */ + protected static boolean isOptimizedWriter(BeanPropertyWriter writer) { + return afterburnerClassChainIncludes(writer.getClass(), "OptimizedBeanPropertyWriter"); + } + + /** Walks the superclass chain of {@code cls} looking for a class whose simple + * name is {@code simpleName}. Used to recognize Afterburner's package-private + * optimized types without importing them. */ + protected static boolean classChainIncludes(Class cls, String simpleName) { + Class c = cls; while (c != null) { - if ("OptimizedSettableBeanProperty".equals(c.getSimpleName()) - && c.getPackageName().startsWith("tools.jackson.module.afterburner")) { + if (simpleName.equals(c.getSimpleName())) { return true; } c = c.getSuperclass(); @@ -143,11 +154,13 @@ protected static boolean isOptimizedProperty(SettableBeanProperty prop) { return false; } - /** True if `writer`'s class chain contains Afterburner's OptimizedBeanPropertyWriter. */ - protected static boolean isOptimizedWriter(BeanPropertyWriter writer) { - Class c = writer.getClass(); + /** Like {@link #classChainIncludes} but additionally requires the matched + * class to live inside an afterburner package. Guards against false positives + * from unrelated classes that happen to share a simple name. */ + protected static boolean afterburnerClassChainIncludes(Class cls, String simpleName) { + Class c = cls; while (c != null) { - if ("OptimizedBeanPropertyWriter".equals(c.getSimpleName()) + if (simpleName.equals(c.getSimpleName()) && c.getPackageName().startsWith("tools.jackson.module.afterburner")) { return true; } diff --git a/afterburner-tests/src/test/java/tools/jackson/module/afterburner/inject/CreatorOptimizerTest.java b/afterburner-tests/src/test/java/tools/jackson/module/afterburner/inject/CreatorOptimizerTest.java index cac13ea4..4f7c8f27 100644 --- a/afterburner-tests/src/test/java/tools/jackson/module/afterburner/inject/CreatorOptimizerTest.java +++ b/afterburner-tests/src/test/java/tools/jackson/module/afterburner/inject/CreatorOptimizerTest.java @@ -114,13 +114,4 @@ private ValueInstantiator instantiatorFor(Class cls) { assertNotNull(inst); return inst; } - - private static boolean classChainIncludes(Class cls, String simpleName) { - Class c = cls; - while (c != null) { - if (simpleName.equals(c.getSimpleName())) return true; - c = c.getSuperclass(); - } - return false; - } } diff --git a/afterburner-tests/src/test/java/tools/jackson/module/afterburner/inject/GeneratedClassCachingTest.java b/afterburner-tests/src/test/java/tools/jackson/module/afterburner/inject/GeneratedClassCachingTest.java new file mode 100644 index 00000000..a71ecfb7 --- /dev/null +++ b/afterburner-tests/src/test/java/tools/jackson/module/afterburner/inject/GeneratedClassCachingTest.java @@ -0,0 +1,120 @@ +package tools.jackson.module.afterburner.inject; + +import org.junit.jupiter.api.Test; + +import tools.jackson.databind.deser.SettableBeanProperty; + +import static org.junit.jupiter.api.Assertions.*; + +// Caching semantics for Afterburner's generated mutator classes. A regression in +// this area would not break functional behavior but would waste class metaspace +// and pin classloaders — so the only way to catch it is to directly inspect the +// generated-class identity. +// +// What's asserted: +// +// 1. Within a single mapper, all OptimizedSettableBeanProperty instances for +// the *same* POJO share the *same* BeanPropertyMutator class. Afterburner +// generates one mutator class per POJO and reuses it across every +// optimized property of that POJO. +// +// 2. Across two independent mappers (each with its own AfterburnerModule +// instance), the mutator class for the same POJO is the *same* Class +// instance. This proves the generated class is cached on the bean's +// parent classloader and not regenerated per mapper — a regression would +// cause classloader bloat under load as short-lived mappers each leaked a +// distinct generated class. +// +// 3. Two *different* POJOs — even ones with identical field shapes — get +// distinct generated mutator classes. Afterburner keys cache entries on +// the bean class name, not on bytecode shape. A regression that conflated +// them would silently deserialize one POJO's JSON into the other's fields. +public class GeneratedClassCachingTest extends AfterburnerInjectionTestBase +{ + public static class FooBean { + public int a; + public String b; + } + + // Structurally identical to FooBean, intentionally — same field names and + // types. Afterburner must still generate a distinct mutator for it because + // the generated class name is derived from the bean class name. + public static class BarBean { + public int a; + public String b; + } + + @Test + public void testMutatorSharedAcrossPropertiesOfSameBean() throws Exception + { + Harness h = newHarness(); + h.mapper.readValue("{\"a\":1,\"b\":\"x\"}", FooBean.class); + + SettableBeanProperty[] props = propsOf(h.deserFor(FooBean.class)); + assertEquals(2, props.length); + + Object mutatorA = reflectField(props[0], "_propertyMutator"); + Object mutatorB = reflectField(props[1], "_propertyMutator"); + assertNotNull(mutatorA); + assertNotNull(mutatorB); + assertSame(mutatorA.getClass(), mutatorB.getClass(), + "all properties of the same POJO should share one mutator class; got " + + mutatorA.getClass().getName() + " vs " + + mutatorB.getClass().getName()); + } + + @Test + public void testSameBeanAcrossMappersReusesSameMutatorClass() throws Exception + { + Harness h1 = newHarness(); + Harness h2 = newHarness(); + + h1.mapper.readValue("{\"a\":1,\"b\":\"x\"}", FooBean.class); + h2.mapper.readValue("{\"a\":2,\"b\":\"y\"}", FooBean.class); + + Class mutatorClass1 = mutatorClassFor(h1, FooBean.class); + Class mutatorClass2 = mutatorClassFor(h2, FooBean.class); + + assertSame(mutatorClass1, mutatorClass2, + "two independent mappers should share the same cached mutator class " + + "for FooBean; got " + mutatorClass1.getName() + " vs " + + mutatorClass2.getName() + " — afterburner may be regenerating " + + "classes instead of caching them on the parent classloader"); + } + + @Test + public void testDifferentBeansGetDistinctMutatorClasses() throws Exception + { + Harness h = newHarness(); + h.mapper.readValue("{\"a\":1,\"b\":\"x\"}", FooBean.class); + h.mapper.readValue("{\"a\":2,\"b\":\"y\"}", BarBean.class); + + Class fooMutator = mutatorClassFor(h, FooBean.class); + Class barMutator = mutatorClassFor(h, BarBean.class); + + assertNotSame(fooMutator, barMutator, + "structurally identical but distinct POJOs must get distinct mutator " + + "classes (otherwise their property ordering could collide); " + + "both got " + fooMutator.getName()); + + // Extra sanity: the generated class names should reflect the bean class names. + assertTrue(fooMutator.getName().contains("FooBean"), + "expected FooBean's mutator class name to mention FooBean; got " + + fooMutator.getName()); + assertTrue(barMutator.getName().contains("BarBean"), + "expected BarBean's mutator class name to mention BarBean; got " + + barMutator.getName()); + } + + /** Reads the `_propertyMutator` field off the first property of the captured + * deserializer and returns its runtime class. */ + private static Class mutatorClassFor(Harness h, Class beanClass) { + SettableBeanProperty[] props = propsOf(h.deserFor(beanClass)); + assertTrue(props.length > 0, "no properties for " + beanClass.getSimpleName()); + Object mutator = reflectField(props[0], "_propertyMutator"); + assertNotNull(mutator, + "property '" + props[0].getName() + "' has no _propertyMutator — " + + "afterburner did not install a generated mutator"); + return mutator.getClass(); + } +} From e65cc4e15a9ad87b1999e7ba89cf19428a7d19af Mon Sep 17 00:00:00 2001 From: Tatu Saloranta Date: Mon, 13 Apr 2026 17:39:55 -0700 Subject: [PATCH 6/8] ... --- .../inject/AfterburnerInjectionTestBase.java | 32 +++++++++++++------ 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/afterburner-tests/src/test/java/tools/jackson/module/afterburner/inject/AfterburnerInjectionTestBase.java b/afterburner-tests/src/test/java/tools/jackson/module/afterburner/inject/AfterburnerInjectionTestBase.java index 05ff4abe..1c759cbb 100644 --- a/afterburner-tests/src/test/java/tools/jackson/module/afterburner/inject/AfterburnerInjectionTestBase.java +++ b/afterburner-tests/src/test/java/tools/jackson/module/afterburner/inject/AfterburnerInjectionTestBase.java @@ -106,11 +106,12 @@ protected static List writersOf(ValueSerializer ser) { /** Walks the class chain of {@code instance} looking for a declared field * named {@code fieldName}, sets it accessible, and returns its value. - * Throws a descriptive AssertionError if the field isn't found — on the - * assumption that a missing field usually means databind renamed/removed - * something, not a bug in the test. */ + * Throws a descriptive AssertionError if the field isn't found, picking + * the hypothesis (databind rename vs caller passed the wrong receiver) + * based on the class's package. */ protected static Object reflectField(Object instance, String fieldName) { - Class c = instance.getClass(); + Class origClass = instance.getClass(); + Class c = origClass; while (c != null) { try { Field f = c.getDeclaredField(fieldName); @@ -120,14 +121,25 @@ protected static Object reflectField(Object instance, String fieldName) { c = c.getSuperclass(); } catch (IllegalAccessException e) { throw new AssertionError("cannot read field '" + fieldName + "' on " - + instance.getClass().getName(), e); + + origClass.getName(), e); } } - throw new AssertionError("expected databind field '" + fieldName + "' on " - + instance.getClass().getName() - + " (walked up full class chain) — databind may have renamed or" - + " removed it; update " + AfterburnerInjectionTestBase.class.getSimpleName() - + " to match."); + // Field genuinely not found anywhere in the class chain. Give the caller + // a hypothesis to start from rather than a bare "not found". + String pkg = origClass.getPackageName(); + String hint; + if (pkg.startsWith("tools.jackson.databind") + || pkg.startsWith("tools.jackson.module.afterburner")) { + hint = "databind or afterburner may have renamed or removed it;" + + " update " + AfterburnerInjectionTestBase.class.getSimpleName() + + " to match."; + } else { + hint = "this looks like the wrong receiver type — '" + fieldName + + "' is an internal Jackson field and the caller passed an" + + " instance of " + origClass.getName() + "."; + } + throw new AssertionError("field '" + fieldName + "' not found on " + + origClass.getName() + " (walked up full class chain) — " + hint); } /** True if `prop`'s class chain contains Afterburner's OptimizedSettableBeanProperty. */ From 761692b415319fd4c6ff1aebddf886f3d8eadc19 Mon Sep 17 00:00:00 2001 From: Tatu Saloranta Date: Mon, 13 Apr 2026 17:51:22 -0700 Subject: [PATCH 7/8] .... --- afterburner-tests/README.md | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/afterburner-tests/README.md b/afterburner-tests/README.md index 91d9f414..1c165ab4 100644 --- a/afterburner-tests/README.md +++ b/afterburner-tests/README.md @@ -71,11 +71,35 @@ artifacts in `target/` rather than a build error. - Private-class guard (`_classLoader != null && isPrivate(beanClass)`). - `setUseValueClassLoader(false)` toggle. -- Generated-class caching across multiple POJOs with the same shape. - Concurrent deserializer construction. - Fallback path when afterburner correctly refuses to optimize (e.g. a bean whose package really is sealed — the in-tree afterburner tests inadvertently cover this). - GraalVM native-image disable path in `AfterburnerModule.setupModule`. -See PR #347 for the rationale behind the current coverage and the open follow-ups. +## Known findings from this module + +### Afterburner parent-classloader caching silently broken on Java 9+ (#348) + +While writing `GeneratedClassCachingTest`, we discovered that Afterburner's +parent-classloader cache — the mechanism that's supposed to make two +independent `ObjectMapper` instances share a single generated mutator class +per POJO — does not work on Java 9+ unless the JVM is launched with +`--add-opens java.base/java.lang=ALL-UNNAMED`. `MyClassLoader` reflects into +`ClassLoader#findLoadedClass` and `ClassLoader#defineClass`, both protected +members of `java.lang.ClassLoader`; on modern JDKs the reflective access +throws `InaccessibleObjectException`, which Afterburner catches silently and +falls back to defining each generated class in a fresh throwaway loader. + +That's why this module's `pom.xml` sets +`--add-opens java.base/java.lang=ALL-UNNAMED` on the surefire `argLine`. +Without it, `testSameBeanAcrossMappersReusesSameMutatorClass` fails with two +distinct `Class` instances carrying identical fully-qualified names — the +exact fingerprint of the leak. This is **not a test-environment quirk**; it +affects every real Afterburner user on Java 9+. See issue #348 for the fix +plan. + +## References + +- PR #347: rationale for the module's structure and initial coverage +- Issue #348: afterburner classloader caching regression (surfaced here) From 35edf7fe6de492c09f1bdc44176806db05b13103 Mon Sep 17 00:00:00 2001 From: Tatu Saloranta Date: Mon, 13 Apr 2026 17:52:44 -0700 Subject: [PATCH 8/8] ... --- release-notes/VERSION | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/release-notes/VERSION b/release-notes/VERSION index fbb560b2..2f172c75 100644 --- a/release-notes/VERSION +++ b/release-notes/VERSION @@ -28,9 +28,11 @@ Active maintainers: `java.lang.IllegalStateException`: Multiple definitions of method getBody found (reported by @bxvs888) (fix by @cowtowncoder, w/ Claude code) -#343: Improve Afterburner testing, access checks +#343: (afterburner) Improve Afterburner testing, access checks + (fix by @cowtowncoder, w/ Claude code) +#347: (afterburner) Added module "afterburner-tests" for better Afterburner-in-classpath + testing (fix by @cowtowncoder, w/ Claude code) -- Added module "afterburner-tests" for better Afterburner-in-classpath testing - Removed project Android SDK level overrides, use defaults (ASDK 34) (affects `android-records` module's verification; was validating ASDK 26)