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)