diff --git a/blackbird-tests/README.md b/blackbird-tests/README.md new file mode 100644 index 00000000..545450af --- /dev/null +++ b/blackbird-tests/README.md @@ -0,0 +1,97 @@ +# jackson-module-blackbird-tests + +Classpath-mode integration tests for `jackson-module-blackbird`. **This module is +test-only and is never published.** + +## Why this module exists + +This module is the Blackbird counterpart to +[`afterburner-tests`](../afterburner-tests/README.md). The coverage gap it +addresses is narrower than Afterburner's, because Blackbird's optimizer +mechanism is structurally different — and better aligned with JPMS — than +Afterburner's. + +**Afterburner:** uses ByteBuddy + `ClassLoader.defineClass` + reflective +access to `java.lang.ClassLoader` methods. On Java 9+, reflective access to +`ClassLoader` is gated by JPMS, and in the JPMS-named test module every test +POJO ends up in a sealed package. That combination silently disables the +optimizer in the in-tree afterburner test suite. See the `afterburner-tests` +README for the full story. + +**Blackbird:** uses `MethodHandles.Lookup.defineClass` (a JDK 9+ supported +API) + `MethodHandles.privateLookupIn`. Neither needs reflective access to +`ClassLoader` methods, neither cares whether the target package is "sealed by +module", and `privateLookupIn` works across the JPMS module boundary whenever +the target module opens its package to the caller (or lives in the unnamed +module). As a result, Blackbird's in-tree tests **do** exercise the optimizer +for setter-based POJOs. + +What's NOT exercised by Blackbird's in-tree tests, and is covered here: + +- **Classpath / unnamed-module POJOs.** A real-world POJO loaded by the + system class loader rather than as part of the Blackbird JPMS module. + This module's test POJOs live in the unnamed module (no `module-info.java` + anywhere), so the optimizer has to cross the module boundary. +- **The documented limitations of Blackbird's optimizer** — specifically + that direct public-field access is **not** optimized on either the + deserializer or serializer side. Blackbird's + `BBDeserializerModifier.nextProperty` and + `BBSerializerModifier.createProperty` both skip non-method members. +- **The `CrossLoaderAccess` fast-path behavior.** Blackbird's + `CrossLoaderAccess` contains a slow-path that defines a + `$$JacksonBlackbirdAccess` companion class via `Lookup.defineClass(byte[])` + to upgrade a partial-privilege lookup. `CrossLoaderAccessTest` pins the + current behavior that on JDK 9+ with an unnamed-module bean, the fast + path always wins and the companion class is never defined. + +## Do not add a `module-info.java` here + +Same reason as `afterburner-tests`: adding a module descriptor would put the +test POJOs back into a named module and change how `privateLookupIn` +resolves them, silently undermining the classpath-coverage purpose. + +## 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. See `afterburner-tests/pom.xml` for the same +pattern with extended commentary; this module's `pom.xml` shares the same +shape. + +## What is covered + +- `BBDeserializerModifier` — setter specializations (int / long / boolean / + String / Object) in `SetterOptimizationTest`. +- `BBSerializerModifier` — getter-based writer specializations in + `SerializerInjectionTest`. +- Field-access negative cases (known design limitation) on both sides, in + `FieldAccessNotOptimizedTest` and `SerializerInjectionTest`. +- `CrossLoaderAccess` fast-path short-circuit for unnamed-module beans, in + `CrossLoaderAccessTest`. + +## What is *not* covered + +- Private-class guard (`Modifier.isPrivate(beanClass)` in both modifiers). +- The slow-path `CrossLoaderAccess.accessClassIn` companion-class definition + — that code is effectively dead for classpath POJOs on JDK 9+; see + `CrossLoaderAccessTest` javadoc for the explanation. +- `CreatorOptimizer` — Blackbird has one; a follow-up could mirror + `afterburner-tests/CreatorOptimizerTest`. +- Concurrent deserializer construction. +- Bean classes in a JPMS module other than Blackbird's own. Testing that + would require a third submodule with its own `module-info.java` that + `opens` its package to Blackbird, which adds complexity for unclear + additional value. +- Lambda-classloader growth as a function of mapper count. Blackbird's + fundamental design creates a new anonymous lambda class per + `LambdaMetafactory.metafactory` invocation; this isn't a bug and isn't + caching-related, so there's nothing to assert. + +## References + +- PR that introduced this module — see git history +- [`afterburner-tests/README.md`](../afterburner-tests/README.md) for the + sibling module and the broader rationale +- Issue #348 — analogous (but different) afterburner classloader caching + issue. Does not apply to Blackbird: Blackbird uses `Lookup.defineClass`, + not reflective `ClassLoader.defineClass`, so it is structurally immune. diff --git a/blackbird-tests/pom.xml b/blackbird-tests/pom.xml new file mode 100644 index 00000000..092fd160 --- /dev/null +++ b/blackbird-tests/pom.xml @@ -0,0 +1,123 @@ + + 4.0.0 + + tools.jackson.module + jackson-modules-base + 3.2.0-SNAPSHOT + + + jackson-module-blackbird-tests + Jackson module: Blackbird integration tests + jar + + Classpath-mode integration tests for jackson-module-blackbird. Unlike +jackson-module-afterburner-tests, Blackbird's in-tree tests already exercise most of +the optimizer because Blackbird uses the JDK 9+ supported MethodHandles.Lookup API +rather than reflective ClassLoader access. What this module adds is coverage of the +classpath/unnamed-module path: when a POJO lives outside Blackbird's own JPMS module, +Blackbird's CrossLoaderAccess has to define a $$JacksonBlackbirdAccess companion class +via Lookup.defineClass to upgrade the caller lookup to one with full privilege access +over the target package. That path is not exercised by in-tree tests (caller and bean +share a module, so privateLookupIn already has full privilege). This module is test-only +and is never published. + + + + tools.jackson.module + jackson-module-blackbird + ${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 + + true + + + + org.gradlex + gradle-module-metadata-maven-plugin + + + default + none + + + + + org.jacoco + jacoco-maven-plugin + + + report + none + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + false + + + + + diff --git a/blackbird-tests/src/test/java/tools/jackson/module/blackbird/inject/BlackbirdInjectionTestBase.java b/blackbird-tests/src/test/java/tools/jackson/module/blackbird/inject/BlackbirdInjectionTestBase.java new file mode 100644 index 00000000..1f357504 --- /dev/null +++ b/blackbird-tests/src/test/java/tools/jackson/module/blackbird/inject/BlackbirdInjectionTestBase.java @@ -0,0 +1,180 @@ +package tools.jackson.module.blackbird.inject; + +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.blackbird.BlackbirdModule; + +import static org.junit.jupiter.api.Assertions.assertNotNull; + +// Test utilities for verifying that Blackbird's lambda-based injection pipeline +// actually ran on a given POJO. Because Blackbird's optimized property and writer +// classes (SettableIntProperty, etc.; IntPropertyWriter, etc.) are package-private +// inside the blackbird module, these checks rely on reflection and simple-name +// matching rather than compile-time type references. Mirrors the afterburner-tests +// harness; see that module's README for the broader rationale. +abstract class BlackbirdInjectionTestBase +{ + 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 BlackbirdModule()) + .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. */ + protected static SettableBeanProperty[] propsOf(ValueDeserializer deser) { + if (!(deser instanceof BeanDeserializer)) { + throw new AssertionError("not a BeanDeserializer: " + deser.getClass().getName()); + } + return (SettableBeanProperty[]) reflectField(deser, "_propsByIndex"); + } + + /** Returns the BeanPropertyWriter[] from a bean serializer, as a list. */ + protected static List writersOf(ValueSerializer ser) { + 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}. Picks the error message hypothesis based on the + * class's package — databind/blackbird fields usually mean a rename, + * anything else means the caller passed the wrong receiver. */ + protected static Object reflectField(Object instance, String fieldName) { + Class origClass = instance.getClass(); + Class c = origClass; + while (c != null) { + try { + Field f = c.getDeclaredField(fieldName); + f.setAccessible(true); + return f.get(instance); + } catch (NoSuchFieldException ignore) { + c = c.getSuperclass(); + } catch (IllegalAccessException e) { + throw new AssertionError("cannot read field '" + fieldName + "' on " + + origClass.getName(), e); + } + } + String pkg = origClass.getPackageName(); + String hint; + if (pkg.startsWith("tools.jackson.databind") + || pkg.startsWith("tools.jackson.module.blackbird")) { + hint = "databind or blackbird may have renamed or removed it;" + + " update " + BlackbirdInjectionTestBase.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 Blackbird's OptimizedSettableBeanProperty. */ + protected static boolean isOptimizedProperty(SettableBeanProperty prop) { + return blackbirdClassChainIncludes(prop.getClass(), "OptimizedSettableBeanProperty"); + } + + /** True if `writer`'s class chain contains Blackbird's OptimizedBeanPropertyWriter. */ + protected static boolean isOptimizedWriter(BeanPropertyWriter writer) { + return blackbirdClassChainIncludes(writer.getClass(), "OptimizedBeanPropertyWriter"); + } + + /** Walks the superclass chain of {@code cls} looking for a class whose simple + * name is {@code simpleName}. Used to recognize Blackbird's package-private + * optimized types without importing them. */ + protected 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; + } + + /** Like {@link #classChainIncludes} but additionally requires the matched + * class to live inside a blackbird package. Guards against false positives + * from unrelated classes that happen to share a simple name. */ + protected static boolean blackbirdClassChainIncludes(Class cls, String simpleName) { + Class c = cls; + while (c != null) { + if (simpleName.equals(c.getSimpleName()) + && c.getPackageName().startsWith("tools.jackson.module.blackbird")) { + return true; + } + c = c.getSuperclass(); + } + return false; + } +} diff --git a/blackbird-tests/src/test/java/tools/jackson/module/blackbird/inject/CrossLoaderAccessTest.java b/blackbird-tests/src/test/java/tools/jackson/module/blackbird/inject/CrossLoaderAccessTest.java new file mode 100644 index 00000000..b775e759 --- /dev/null +++ b/blackbird-tests/src/test/java/tools/jackson/module/blackbird/inject/CrossLoaderAccessTest.java @@ -0,0 +1,88 @@ +package tools.jackson.module.blackbird.inject; + +import org.junit.jupiter.api.Test; + +import tools.jackson.databind.deser.SettableBeanProperty; + +import static org.junit.jupiter.api.Assertions.*; + +// Documents a finding about Blackbird's CrossLoaderAccess code path: +// on JDK 9+ with a bean in the unnamed module (classpath), the companion +// `$$JacksonBlackbirdAccess` class is **never** defined. +// +// Why: BBDeserializerModifier.updateBuilder obtains a lookup via +// MethodHandles.lookup() inside BlackbirdModule (full privilege on blackbird's +// own class), then calls ReflectionHack.privateLookupIn(beanClass, lookup). +// For an unnamed-module bean, privateLookupIn always succeeds and returns a +// lookup with hasFullPrivilegeAccess() == true over the bean's package. Then +// CrossLoaderAccess.grantAccess short-circuits on `hasFullAccess(lookup)` and +// returns the lookup unchanged, skipping the slow path that would define a +// $$JacksonBlackbirdAccess companion class via Lookup.defineClass(byte[]). +// +// That slow path exists for historical / edge-case scenarios (JDK 8 when +// DEFINE_CLASS is null, or a future JDK change that lowers privileges on +// privateLookupIn). It is not exercised by either Blackbird's in-tree test +// suite (same-module case is also full-privilege) nor by this classpath +// test module. +// +// This test pins the current behavior: the fast path must win, and the +// companion class must not be introduced as a side effect of deserialization. +// If Blackbird's CrossLoaderAccess logic changes such that the slow path +// starts firing for classpath POJOs, this test will catch it — at which +// point the maintainer should decide whether that's intended (and update the +// assertion) or accidental (and revert). +public class CrossLoaderAccessTest extends BlackbirdInjectionTestBase +{ + public static class XLoaderBean { + private int a; + private String b; + public int getA() { return a; } + public void setA(int a) { this.a = a; } + public String getB() { return b; } + public void setB(String b) { this.b = b; } + } + + private static final String COMPANION_CLASS_NAME = + XLoaderBean.class.getPackage().getName() + ".$$JacksonBlackbirdAccess"; + + @Test + public void testFastPathWins_NoCompanionClassDefined() throws Exception + { + Harness h = newHarness(); + + // Trigger Blackbird's modifier chain for XLoaderBean. + XLoaderBean bean = h.mapper.readValue("{\"a\":1,\"b\":\"hi\"}", XLoaderBean.class); + assertEquals(1, bean.getA()); + assertEquals("hi", bean.getB()); + + // Optimization still ran — setters are Blackbird-optimized. + SettableBeanProperty[] props = propsOf(h.deserFor(XLoaderBean.class)); + assertEquals(2, props.length); + for (SettableBeanProperty p : props) { + assertTrue(isOptimizedProperty(p), + "XLoaderBean." + p.getName() + " not optimized: " + + p.getClass().getName()); + } + + // But the $$JacksonBlackbirdAccess companion class must NOT have been + // defined — CrossLoaderAccess.grantAccess short-circuits for a lookup + // with hasFullPrivilegeAccess() == true, which is what privateLookupIn + // returns for an unnamed-module target. + assertFalse(companionClassExists(), + "CrossLoaderAccess unexpectedly defined " + COMPANION_CLASS_NAME + + " for an unnamed-module bean — the grantAccess fast path" + + " should have short-circuited. If Blackbird has started" + + " taking the slow path (e.g. because privateLookupIn" + + " semantics changed), update this test."); + } + + private static boolean companionClassExists() { + try { + Class.forName(COMPANION_CLASS_NAME, false, + XLoaderBean.class.getClassLoader()); + return true; + } catch (ClassNotFoundException e) { + return false; + } + } +} diff --git a/blackbird-tests/src/test/java/tools/jackson/module/blackbird/inject/FieldAccessNotOptimizedTest.java b/blackbird-tests/src/test/java/tools/jackson/module/blackbird/inject/FieldAccessNotOptimizedTest.java new file mode 100644 index 00000000..f36ee1cf --- /dev/null +++ b/blackbird-tests/src/test/java/tools/jackson/module/blackbird/inject/FieldAccessNotOptimizedTest.java @@ -0,0 +1,53 @@ +package tools.jackson.module.blackbird.inject; + +import org.junit.jupiter.api.Test; + +import tools.jackson.databind.deser.SettableBeanProperty; + +import static org.junit.jupiter.api.Assertions.*; + +// Documents a known design limitation of Blackbird: direct public-field access +// is NOT optimized. BBDeserializerModifier.nextProperty only handles properties +// whose backing JDK member is a Method (setter); if it's a Field, the method +// returns early and the property is left as a plain FieldProperty. Afterburner +// optimizes both setter and field access; Blackbird deliberately doesn't. +// +// The point of this test is to pin that contract. If Blackbird ever grows +// field-access support, this test will start failing — which is the correct +// signal to update it. +public class FieldAccessNotOptimizedTest extends BlackbirdInjectionTestBase +{ + public static class FieldOnlyBean { + public int intField; + public long longField; + public boolean boolField; + public String stringField; + } + + private final Harness h = newHarness(); + + @Test + public void testFieldPropsAreNotReplacedWithOptimizedVersions() throws Exception + { + // End-to-end deserialization must still work — Blackbird just delegates + // to databind's plain FieldProperty for these. + FieldOnlyBean bean = h.mapper.readValue( + "{\"intField\":1,\"longField\":2,\"boolField\":true,\"stringField\":\"x\"}", + FieldOnlyBean.class); + assertEquals(1, bean.intField); + assertEquals(2L, bean.longField); + assertTrue(bean.boolField); + assertEquals("x", bean.stringField); + + // None of the properties should be Blackbird-optimized. + SettableBeanProperty[] props = propsOf(h.deserFor(FieldOnlyBean.class)); + assertEquals(4, props.length); + for (SettableBeanProperty p : props) { + assertFalse(isOptimizedProperty(p), + "Blackbird unexpectedly optimized field property '" + p.getName() + + "' (is " + p.getClass().getName() + "). If Blackbird" + + " has grown field-access support, update this test to" + + " assert the positive case instead."); + } + } +} diff --git a/blackbird-tests/src/test/java/tools/jackson/module/blackbird/inject/SerializerInjectionTest.java b/blackbird-tests/src/test/java/tools/jackson/module/blackbird/inject/SerializerInjectionTest.java new file mode 100644 index 00000000..41f6ada6 --- /dev/null +++ b/blackbird-tests/src/test/java/tools/jackson/module/blackbird/inject/SerializerInjectionTest.java @@ -0,0 +1,87 @@ +package tools.jackson.module.blackbird.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 Blackbird's serializer-side optimization runs end-to-end for all +// accessor-method specializations, and documents the parallel limitation with +// the deserializer side: direct public-field writers are not optimized either. +// BBSerializerModifier.createProperty skips properties whose backing JDK +// member isn't an AnnotatedMethod (see BBSerializerModifier.java lines +// 103-109), so public fields stay as plain BeanPropertyWriter instances. +public class SerializerInjectionTest extends BlackbirdInjectionTestBase +{ + public static class GetterSerPojo { + private int intProp; + private long longProp; + private boolean boolProp; + private String stringProp; + private java.util.List objectProp; + + public GetterSerPojo() { } + + public GetterSerPojo(int i, long l, boolean b, String s, java.util.List o) { + intProp = i; longProp = l; boolProp = b; stringProp = s; objectProp = o; + } + + public int getIntProp() { return intProp; } + public long getLongProp() { return longProp; } + public boolean isBoolProp() { return boolProp; } + public String getStringProp() { return stringProp; } + public java.util.List getObjectProp() { return objectProp; } + } + + public static class FieldSerPojo { + public int value; + + public FieldSerPojo() { } + public FieldSerPojo(int v) { this.value = v; } + } + + @Test + public void testAllGetterBasedWritersOptimized() throws Exception + { + Harness h = newHarness(); + + String json = h.mapper.writeValueAsString( + new GetterSerPojo(1, 2L, true, "x", java.util.Arrays.asList("a", "b"))); + assertTrue(json.contains("\"intProp\":1"), json); + assertTrue(json.contains("\"longProp\":2"), json); + assertTrue(json.contains("\"boolProp\":true"), json); + assertTrue(json.contains("\"stringProp\":\"x\""), json); + assertTrue(json.contains("\"objectProp\":[\"a\",\"b\"]"), json); + + List writers = writersOf(h.serFor(GetterSerPojo.class)); + assertEquals(5, writers.size(), "expected 5 writers, got " + writers); + for (BeanPropertyWriter w : writers) { + assertTrue(isOptimizedWriter(w), + "ser writer '" + w.getName() + "' not optimized (is " + + w.getClass().getName() + "); BBSerializerModifier" + + " did not replace it with a Blackbird-generated writer"); + } + } + + @Test + public void testFieldBackedWriterNotOptimized() throws Exception + { + Harness h = newHarness(); + + // Serialization still works end-to-end, just not via a Blackbird-generated accessor. + String json = h.mapper.writeValueAsString(new FieldSerPojo(42)); + assertTrue(json.contains("\"value\":42"), json); + + List writers = writersOf(h.serFor(FieldSerPojo.class)); + assertEquals(1, writers.size()); + BeanPropertyWriter w = writers.get(0); + assertFalse(isOptimizedWriter(w), + "Blackbird unexpectedly optimized a field-backed writer '" + w.getName() + + "' (is " + w.getClass().getName() + "). If BBSerializerModifier" + + " has grown field-access support, update this test to assert" + + " the positive case instead."); + } +} diff --git a/blackbird-tests/src/test/java/tools/jackson/module/blackbird/inject/SetterOptimizationTest.java b/blackbird-tests/src/test/java/tools/jackson/module/blackbird/inject/SetterOptimizationTest.java new file mode 100644 index 00000000..93db3d55 --- /dev/null +++ b/blackbird-tests/src/test/java/tools/jackson/module/blackbird/inject/SetterOptimizationTest.java @@ -0,0 +1,92 @@ +package tools.jackson.module.blackbird.inject; + +import org.junit.jupiter.api.Test; + +import tools.jackson.databind.deser.SettableBeanProperty; + +import static org.junit.jupiter.api.Assertions.*; + +// End-to-end verification that Blackbird's setter optimization runs on POJOs +// loaded from the unnamed module (classpath). The specializations tested here +// correspond to BBDeserializerModifier's four primitive branches (int, long, +// boolean, object/String) plus the object/non-String branch. +// +// Note: Blackbird does NOT optimize direct public-field access — only setter +// methods. See FieldAccessNotOptimizedTest for the documented negative case. +public class SetterOptimizationTest extends BlackbirdInjectionTestBase +{ + 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; } + } + public static class ObjectSetterBean { + private java.util.List value; + public java.util.List getValue() { return value; } + public void setValue(java.util.List v) { this.value = v; } + } + + private final Harness h = newHarness(); + + @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); + } + + @Test + public void testObjectSetter() throws Exception { + ObjectSetterBean b = h.mapper.readValue("{\"value\":[\"a\",\"b\"]}", ObjectSetterBean.class); + assertEquals(2, b.getValue().size()); + assertEquals("a", b.getValue().get(0)); + assertAllPropsOptimized(ObjectSetterBean.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() + + " — BBDeserializerModifier did not install a SettableXProperty"); + } + } +} diff --git a/blackbird/src/main/java/tools/jackson/module/blackbird/CrossLoaderAccess.java b/blackbird/src/main/java/tools/jackson/module/blackbird/CrossLoaderAccess.java index ef33af65..2c064276 100644 --- a/blackbird/src/main/java/tools/jackson/module/blackbird/CrossLoaderAccess.java +++ b/blackbird/src/main/java/tools/jackson/module/blackbird/CrossLoaderAccess.java @@ -10,6 +10,13 @@ class CrossLoaderAccess implements UnaryOperator { private static final MethodHandle DEFINE_CLASS, HAS_FULL_ACCESS; + + /** + * @deprecated Since 3.2. Only referenced by {@link #accessClassIn}, which + * is dead code on JDK 9+ for all known inputs. Scheduled for removal + * together with the companion-class slow path. + */ + @Deprecated(since = "3.2", forRemoval = true) private static final String CLASS_NAME = "$$JacksonBlackbirdAccess"; // Pre-compiled Java 8 bytecode: @@ -18,6 +25,10 @@ class CrossLoaderAccess implements UnaryOperator { // public static final MethodHandles.Lookup LOOKUP = MethodHandles.lookup(); // } + /** + * @deprecated Since 3.2. See {@link #accessClassIn}. + */ + @Deprecated(since = "3.2", forRemoval = true) private static final int[] HEADER = new int[] { 0xca, 0xfe, 0xba, 0xbe, 0x00, 0x00, 0x00, 0x34, 0x00, 0x1c, 0x0a, 0x00, 0x02, 0x00, 0x03, 0x07, 0x00, 0x04, 0x0c, 0x00, 0x05, 0x00, 0x06, 0x01, @@ -35,6 +46,10 @@ class CrossLoaderAccess implements UnaryOperator { 0x09, 0x00, 0x0e, 0x00, 0x0f, 0x07, 0x00, 0x10, 0x0c, 0x00, 0x11, 0x00, 0x12, 0x01 }; + /** + * @deprecated Since 3.2. See {@link #accessClassIn}. + */ + @Deprecated(since = "3.2", forRemoval = true) private static final int[] FOOTER = new int[] { 0x01, 0x00, 0x06, 0x4c, 0x4f, 0x4f, 0x4b, 0x55, 0x50, 0x01, 0x00, 0x27, 0x4c, 0x6a, 0x61, 0x76, 0x61, 0x2f, 0x6c, 0x61, 0x6e, 0x67, 0x2f, 0x69, @@ -88,9 +103,17 @@ public MethodHandles.Lookup apply(MethodHandles.Lookup lookup) { } private static MethodHandles.Lookup grantAccess(MethodHandles.Lookup lookup) throws IOException, ReflectiveOperationException { + // Fast path: on JDK 9+, privateLookupIn always yields a full-privilege + // lookup for any bean class Blackbird can see, so this branch is taken + // for every real-world call and the companion-class slow path below + // is effectively unreachable. Verified by `blackbird-tests` + // CrossLoaderAccessTest; see that test's javadoc for the full rationale. if (DEFINE_CLASS == null || hasFullAccess(lookup)) { return lookup; } + // Legacy slow path, retained while we finish verifying it has no live + // callers in any supported JDK / lookup configuration. Scheduled for + // removal — see `accessClassIn` javadoc. return (MethodHandles.Lookup) accessClassIn(lookup).getField("LOOKUP").get(null); } @@ -104,6 +127,27 @@ private static boolean hasFullAccess(MethodHandles.Lookup lookup) { } } + /** + * Defines a {@code $$JacksonBlackbirdAccess} companion class in the same + * package as {@code lookup.lookupClass()} and returns it. The companion + * class exposes a {@code public static final MethodHandles.Lookup LOOKUP} + * field (initialized from a class-initializer calling + * {@link MethodHandles#lookup()}), which callers then use as a + * full-privilege lookup for operations in that package. + * + * @deprecated Since 3.2. This is legacy Java 8 / pre-{@code privateLookupIn} + * fallback logic that is no longer reachable for any known input on + * JDK 9+. The {@link #grantAccess} fast path always wins because + * {@link MethodHandles#privateLookupIn} returns a lookup with + * {@link MethodHandles.Lookup#hasFullPrivilegeAccess()} {@code == true} + * for any bean class Blackbird can see. The + * {@code jackson-module-blackbird-tests} module + * {@code CrossLoaderAccessTest} pins this behavior. If a real live + * caller for this slow path is identified before 4.0, remove the + * {@link Deprecated} marker and keep the method; otherwise, delete + * along with {@link #CLASS_NAME}, {@link #HEADER}, and {@link #FOOTER}. + */ + @Deprecated(since = "3.2", forRemoval = true) private static Class accessClassIn(MethodHandles.Lookup lookup) throws IOException, ReflectiveOperationException { Package pkg = lookup.lookupClass().getPackage(); final String pkgName = pkg.getName(); diff --git a/pom.xml b/pom.xml index ea8ef52d..63aafb3b 100644 --- a/pom.xml +++ b/pom.xml @@ -28,6 +28,8 @@ not datatype, data format, or JAX-RS provider modules. afterburner-tests android-record blackbird + + blackbird-tests guice guice7 jakarta-xmlbind diff --git a/release-notes/VERSION b/release-notes/VERSION index 2f172c75..03537592 100644 --- a/release-notes/VERSION +++ b/release-notes/VERSION @@ -33,6 +33,9 @@ Active maintainers: #347: (afterburner) Added module "afterburner-tests" for better Afterburner-in-classpath testing (fix by @cowtowncoder, w/ Claude code) +#349 (blackbird) Added module "blackbird-tests" for classpath-mode Blackbird coverage + (setter specializations, field-access limitation, CrossLoaderAccess fast-path) + (fix by @cowtowncoder, w/ Claude code) - Removed project Android SDK level overrides, use defaults (ASDK 34) (affects `android-records` module's verification; was validating ASDK 26)