Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
0774ad4
Build Blackbird on JDK 25 as a multi-release jar
stevenschlansker Oct 15, 2026
a92af0d
Replace the Blackbird deserializer engine with generated per-bean rea…
stevenschlansker Oct 15, 2026
1e2d0ca
Generate readers for records and builder-based beans
stevenschlansker Oct 15, 2026
24045db
Replace the Blackbird serializer engine with generated per-bean writers
stevenschlansker Oct 15, 2026
01bdb86
Remove the rest of the LambdaMetafactory engine
stevenschlansker Oct 15, 2026
fae8c0a
Add differential tests of Blackbird against stock databind
stevenschlansker Oct 15, 2026
a3849dd
Update the Blackbird benchmarks for the generated-codec engine
stevenschlansker Oct 15, 2026
3cfed62
Document the new Blackbird engine
stevenschlansker Oct 15, 2026
1d91be5
Add the databind test corpus as a manual check for Blackbird changes
stevenschlansker Oct 15, 2026
51e03aa
Merge branch '3.x' into claude-blackbird-next
cowtowncoder Oct 25, 2026
fb6ec28
Blackbird: fix varargs constructor/setter handles in generated codecs
cowtowncoder Oct 26, 2026
74e4e02
Blackbird: sanitize '<' and '>' in class-data entry names
cowtowncoder Oct 26, 2026
bb56884
Blackbird: write view-hidden properties as omitted in generated writers
cowtowncoder Oct 26, 2026
474a8a9
Blackbird: support hidden bean classes in generated codec names
cowtowncoder Oct 26, 2026
cdc481f
Blackbird: do not demote VirtualMachineErrors raised during codegen
cowtowncoder Oct 26, 2026
8c12781
When generation was gated or failed, the placeholder stayed as the
cowtowncoder Oct 26, 2026
a447fa7
The scalar fast paths recognized databind's String/int/long/boolean
cowtowncoder Oct 26, 2026
98562b7
GeneratedReaderBase and GeneratedWriterBase each had an identical
cowtowncoder Oct 26, 2026
080a8f7
The modifiers stored a lookup Function they never read, and setupModule
cowtowncoder Oct 26, 2026
426e64b
About a dozen test classes each re-implemented a capture module that
cowtowncoder Oct 26, 2026
97c798a
Blackbird: contextualize placeholders before handing out stock
cowtowncoder Oct 26, 2026
2ab16c2
Blackbird: deprecate the unused lookup hooks, fix stale docs
cowtowncoder Oct 26, 2026
8804631
Blackbird tests: make lookup tests meaningful, use hasGeneratedCodec
cowtowncoder Oct 26, 2026
e95a7d1
Blackbird: share the class-data constant, deprecate lookup wrap helper
cowtowncoder Oct 26, 2026
89ca889
Blackbird build: never publish jpms-tests, fix the clean-build manifest
cowtowncoder Oct 26, 2026
25ad3ba
Blackbird: honor views set in code and custom filtered writers
cowtowncoder Oct 26, 2026
f584254
Blackbird: make the stock checks generated readers skipped
cowtowncoder Oct 26, 2026
61ad35d
Blackbird: keep writer codegen alive after a failure, read self refs …
cowtowncoder Oct 26, 2026
237ff76
Build Blackbird on databind pushes, document its JDK 25 gate
cowtowncoder Oct 26, 2026
ae40e6d
Run Blackbird old-JDK tests on databind pushes, fix CLAUDE.md
cowtowncoder Oct 27, 2026
db7183e
Blackbird: address review findings on codegen, references, views
cowtowncoder Oct 27, 2026
2c99381
Blackbird: match stock on record finish order, builder error names
cowtowncoder Oct 27, 2026
a6b3f88
Merge branch '3.x' into claude-blackbird-next
cowtowncoder Oct 29, 2026
e079d85
Blackbird: builder failure names from the input name, not the entry c…
cowtowncoder Oct 30, 2026
42bc8bb
Merge branch '3.x' into claude-blackbird-next
cowtowncoder Oct 30, 2026
125ac6e
Merge branch '3.x' into claude-blackbird-next
cowtowncoder Oct 30, 2026
04c6eac
Blackbird: emitter initialization failure no longer breaks discovery
Oct 30, 2026
a11a9b5
Merge branch '3.x' into claude-blackbird-next
cowtowncoder Oct 30, 2026
70ce01d
Blackbird: match stock on odd token after unknown name; guard missing…
cowtowncoder Oct 30, 2026
af5ba85
Blackbird: follow databind#6325 for null records; strict codegen via …
cowtowncoder Oct 30, 2026
392b6b9
Blackbird: records end on odd token like stock; vanilla tracking via …
cowtowncoder Oct 31, 2026
bf191c2
Blackbird: enforce store handles in the reader plan instead of fallin…
cowtowncoder Oct 31, 2026
06548f7
Blackbird: skip codecs for array shape, allow private builders, reuse…
cowtowncoder Oct 31, 2026
ef6cab6
Blackbird: defer stack-overflow logging; array-shape gate in the fact…
cowtowncoder Oct 31, 2026
8a68894
Blackbird: drop stack-overflow logging, back to silent stock fallback
cowtowncoder Oct 31, 2026
05ad5a6
Blackbird: fail on stack overflow in strict mode; trace outside the try
cowtowncoder Oct 31, 2026
2f6dfc7
Blackbird: document silent fallback when generation runs out of stack
cowtowncoder Oct 31, 2026
ad98ce8
Blackbird: document array-shape gate; clearer gate trace
cowtowncoder Oct 31, 2026
ff2de36
Blackbird: read strict-mode flag once for the stack overflow handler
cowtowncoder Nov 1, 2026
e8c5a10
Blackbird: one strict-mode constant for all codegen fallback checks
cowtowncoder Nov 1, 2026
9be83fc
Blackbird: pass errors from codec construction through unwrapped
cowtowncoder Nov 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 25 additions & 2 deletions .github/workflows/dep_build_v3.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,20 +15,43 @@ jobs:
strategy:
fail-fast: false
matrix:
java_version: ['17', '21']
# 17, 21: all modules except Blackbird (profile-gated to JDK 25+).
# 25: only the Blackbird modules, which the other two leave out.
java_version: ['17', '21', '25']
env:
JAVA_OPTS: "-XX:+TieredCompilation -XX:TieredStopAtLevel=1"
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: 3.x
# Blackbird builds only on JDK 25+, but its jar must run on 17-24 too
# (module discovery skips it there); blackbird-tests starts those JDKs.
# Keep in sync with main.yml.
- name: Set up older JDKs for Blackbird runtime tests
if: ${{ matrix.java_version == '25' }}
uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
with:
distribution: 'temurin'
java-version: |
17
21
- name: Set up JDK
uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
with:
distribution: 'temurin'
java-version: ${{ matrix.java_version }}
cache: 'maven'
- name: Build and test
run: ./mvnw -B -ff -ntp clean verify
run: |
BUILD_ARGS=""
if [ "${{ matrix.java_version }}" = "25" ]; then
if [ -z "${JAVA_HOME_17_X64:-}" ] || [ -z "${JAVA_HOME_21_X64:-}" ]; then
echo "::error::JDK 17/21 homes not found; Blackbird old-JDK tests would be skipped"
exit 1
fi
BUILD_ARGS="-pl blackbird,blackbird-tests,blackbird-jpms-tests -am"
BUILD_ARGS="${BUILD_ARGS} -Dblackbird.test.oldJdks=${JAVA_HOME_17_X64},${JAVA_HOME_21_X64}"
fi
./mvnw -B -ff -ntp clean verify ${BUILD_ARGS}

# No recursive rebuild (yet?)
25 changes: 23 additions & 2 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,24 @@ jobs:
matrix:
java_version: ['17', '21', '24', '25']
include:
- java_version: '17'
# Release/deploy runs on 25 so the blackbird module (profile-gated
# to JDK 25+) is part of the deployed reactor.
- java_version: '25'
release_build: 'R'
env:
JAVA_OPTS: "-XX:+TieredCompilation -XX:TieredStopAtLevel=1"
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Blackbird builds only on JDK 25+, but its jar must run on 17-24 too
# (module discovery skips it there); blackbird-tests starts those JDKs.
- name: Set up older JDKs for Blackbird runtime tests
if: ${{ matrix.java_version == '25' }}
uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
with:
distribution: 'temurin'
java-version: |
17
21
- name: Set up JDK
uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
with:
Expand All @@ -39,7 +51,16 @@ jobs:
# gpg-private-key: ${{ secrets.MAVEN_GPG_PRIVATE_KEY }} # Value of the GPG private key to import
# gpg-passphrase: MAVEN_GPG_PASSPHRASE # env variable for GPG private key passphrase
- name: Build
run: ./mvnw -B -ff -ntp clean verify
run: |
OLD_JDKS=""
if [ "${{ matrix.java_version }}" = "25" ]; then
if [ -z "${JAVA_HOME_17_X64:-}" ] || [ -z "${JAVA_HOME_21_X64:-}" ]; then
echo "::error::JDK 17/21 homes not found; Blackbird old-JDK tests would be skipped"
exit 1
fi
OLD_JDKS="-Dblackbird.test.oldJdks=${JAVA_HOME_17_X64},${JAVA_HOME_21_X64}"
fi
./mvnw -B -ff -ntp clean verify ${OLD_JDKS}
- name: Extract project Maven version
id: projectVersion
run: echo "version=$(./mvnw org.apache.maven.plugins:maven-help-plugin:3.5.1:evaluate -DforceStdout -Dexpression=project.version -q)" >> $GITHUB_OUTPUT
Expand Down
15 changes: 10 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ Jackson modules-base is a multi-module Maven project containing 11 foundational

(paranamer is not part of 3.x — it only appears commented out in the root `pom.xml`.)

There are also two test-only, never-published modules: `afterburner-tests` and `blackbird-tests`. They have no `module-info.java`, so their test POJOs live in the unnamed module and exercise the bytecode-generation paths that are disabled for beans in named JPMS modules (see each module's `README.md`).
There are also three test-only, never-published modules: `afterburner-tests` and `blackbird-tests` have no `module-info.java`, so their test POJOs live in the unnamed module, as in most applications (see each module's `README.md`). For Afterburner this covers the bytecode-generation paths it disables for beans in named JPMS modules; Blackbird's in-tree tests run inside Blackbird's own named module, and `blackbird-tests` also holds the tests that need the packaged jar or other JDKs; `blackbird-jpms-tests` is a foreign named module that requires blackbird and runs on the module path.

**Blackbird builds only on JDK 25+:** `blackbird`, `blackbird-tests` and `blackbird-jpms-tests` are in the root pom's `blackbird-jdk25` profile (activated on JDK 25+). On an older JDK the full build silently leaves them out, and `-pl blackbird` fails with "Could not find the selected project in the reactor"; use a JDK 25 (`JAVA_HOME=... ./mvnw ...`) for Blackbird work.

Maven coordinates: groupId `tools.jackson.module`, parent `tools.jackson:jackson-base`; depends on `tools.jackson.core:jackson-databind`.

Expand All @@ -27,7 +29,10 @@ Maven coordinates: groupId `tools.jackson.module`, parent `tools.jackson:jackson
./mvnw -B -ff -ntp test

# Run tests for a single module
./mvnw -B -ff -ntp test -pl blackbird
./mvnw -B -ff -ntp test -pl mrbean

# Blackbird modules need JDK 25+ (not in the reactor on older JDKs)
JAVA_HOME=/path/to/jdk25 ./mvnw -B -ff -ntp test -pl blackbird

# Run a single test class
./mvnw -B -ff -ntp test -pl afterburner -Dtest=TestSimpleSerialize
Expand All @@ -36,20 +41,20 @@ Maven coordinates: groupId `tools.jackson.module`, parent `tools.jackson:jackson
./mvnw -B -ff -ntp test -pl afterburner -Dtest=BasicDeserializeTest#testIntMethod
```

Java 17 is the minimum (`javac.src.version` 17); CI tests against 17, 21, 24, 25. osgi and afterburner-tests add `--add-opens java.base/java.lang=ALL-UNNAMED` to their test `argLine`.
Java 17 is the minimum (`javac.src.version` 17); CI (`main.yml`) tests against 17, 21, 24, 25; Blackbird builds only in the 25 job, which also runs its runtime tests on 17 and 21 through `-Dblackbird.test.oldJdks`. The databind-push rebuild (`dep_build_v3.yml`) builds all other modules on 17 and 21 and only the Blackbird modules on 25, with the same old-JDK tests; keep the old-JDK wiring of the two workflows in sync. osgi and afterburner-tests add `--add-opens java.base/java.lang=ALL-UNNAMED` to their test `argLine`.

## Architecture

Every module follows the same pattern:
- A `*Module` class (e.g., `AfterburnerModule`, `BlackbirdModule`) extends Jackson's `tools.jackson.databind.JacksonModule`, registered on a mapper builder (e.g., `JsonMapper.builder().addModule(...)`)
- Package: `tools.jackson.module.<name>` (hyphens dropped or turned into sub-packages: `androidrecord`, `noctordeser`, `spisubtypes`, `jakarta.xmlbind`)
- `PackageVersion.java` is generated from `PackageVersion.java.in` by the replacer Maven plugin — do not edit manually
- JPMS module descriptors are real sources: `<module>/src/main/java/module-info.java` (and `src/test/java/module-info.java` for tests). Moditect is no longer used; stray `src/moditect/` dirs in blackbird and android-record are leftovers
- JPMS module descriptors are real sources: `<module>/src/main/java/module-info.java` (and `src/test/java/module-info.java` for tests). Moditect is no longer used; the stray `src/moditect/` dir in android-record is a leftover
- SPI registration: `src/main/resources/META-INF/services/tools.jackson.databind.JacksonModule` (all modules except guice, guice7, osgi)

### Key module categories

- **Performance (bytecode generation):** afterburner (Byte Buddy-based), blackbird (LambdaMetafactory-based, modern replacement) — both disable themselves automatically in GraalVM native images via `NativeImageUtil.isInNativeImage()` check in `setupModule`
- **Performance (bytecode generation):** afterburner (Byte Buddy-based), blackbird (generated per-bean codecs through the JDK ClassFile API; a multi-release jar that builds on JDK 25+ and is active only on 25+; on Java 17-24 module discovery skips it and explicit `addModule` throws `IllegalStateException`, see `blackbird/README.md`) — both disable themselves automatically in GraalVM native images via `NativeImageUtil.isInNativeImage()` check in `setupModule`
- **DI integration:** guice (javax.inject), guice7 (jakarta.inject) — note: guice's `ObjectMapperModule` implements Guice's `com.google.inject.Module`, not Jackson's `JacksonModule`
- **Type materialization:** mrbean (generates implementations for interfaces/abstract classes, Byte Buddy-based)
- **Annotation support:** jaxb (javax.xml.bind), jakarta-xmlbind (jakarta.xml.bind)
Expand Down
177 changes: 177 additions & 0 deletions blackbird-jpms-tests/pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>tools.jackson.module</groupId>
<artifactId>jackson-modules-base</artifactId>
<version>3.3.0-SNAPSHOT</version>
</parent>
<artifactId>jackson-module-blackbird-jpms-tests</artifactId>
<name>Jackson module: Blackbird JPMS integration tests</name>
<packaging>jar</packaging>
<description>Module-path integration tests: a foreign named module that
requires blackbird, exercising codec generation for its own non-public
classes. Not published.</description>

<properties>
<maven.compiler.release>25</maven.compiler.release>
<javac.src.version>25</javac.src.version>
<javac.target.version>25</javac.target.version>
</properties>

<build>
<plugins>
<!-- This module is test-only and must never produce a publishable artifact.
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 blackbird-jpms-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. See afterburner-tests/pom.xml
for the same pattern with more detailed comments. -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<executions>
<execution>
<id>default-jar</id>
<phase>none</phase>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-install-plugin</artifactId>
<configuration>
<skip>true</skip>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-deploy-plugin</artifactId>
<configuration>
<skip>true</skip>
</configuration>
</plugin>
<!-- See afterburner-tests/pom.xml for why this is required in addition
to skipping maven-deploy-plugin. -->
<plugin>
<groupId>org.sonatype.central</groupId>
<artifactId>central-publishing-maven-plugin</artifactId>
<executions>
<execution>
<id>injected-central-publishing</id>
<phase>none</phase>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.felix</groupId>
<artifactId>maven-bundle-plugin</artifactId>
<executions>
<execution>
<id>bundle-manifest</id>
<phase>none</phase>
</execution>
<execution>
<id>default-install</id>
<phase>none</phase>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.cyclonedx</groupId>
<artifactId>cyclonedx-maven-plugin</artifactId>
<configuration>
<skip>true</skip>
</configuration>
</plugin>
<plugin>
<groupId>org.gradlex</groupId>
<artifactId>gradle-module-metadata-maven-plugin</artifactId>
<executions>
<execution>
<id>default</id>
<phase>none</phase>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<executions>
<execution>
<id>report</id>
<phase>none</phase>
</execution>
</executions>
</plugin>
<!-- The `release` profile (inherited from oss-parent, activated by the
`-Prelease` argument that maven-release-plugin passes to its
`clean verify` preparation build) binds maven-source-plugin,
maven-javadoc-plugin and maven-gpg-plugin. All three assume a main
artifact exists, but `default-jar` is unbound above, so GPG signing
fails with "project artifact has not been assigned a file" and the
whole `release:prepare` aborts. These plugins only run under
`-Prelease`, so skipping them here has no effect on normal builds.
See afterburner-tests/pom.xml for the same pattern. -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-source-plugin</artifactId>
<configuration>
<skipSource>true</skipSource>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-javadoc-plugin</artifactId>
<configuration>
<skip>true</skip>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-gpg-plugin</artifactId>
<configuration>
<skip>true</skip>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<!-- These tests assert module-path semantics (what a named module
can and cannot reach); the reactor-wide classpath toggle
(-Dsurefire.useModulePath=false) must not apply here. -->
<useModulePath>true</useModulePath>
<!-- The tests assert codec engagement through the generation trace. -->
<systemPropertyVariables>
<blackbird.debug.codegen>true</blackbird.debug.codegen>
<!-- Strict mode for every test class, whatever loads first: a
codec-generation failure fails the test instead of demoting
to the stock path (see CodegenFallbacks). -->
<tools.jackson.module.blackbird.failOnCodegenError>true</tools.jackson.module.blackbird.failOnCodegenError>
</systemPropertyVariables>
</configuration>
</plugin>
</plugins>
</build>

<dependencies>
<dependency>
<groupId>tools.jackson.module</groupId>
<artifactId>jackson-module-blackbird</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package blackbird.jpms.test.beans;

import java.lang.invoke.MethodHandles;

/** Hands tests this module's lookup and its non-public bean type. */
public final class Beans {
private Beans() {}

public static MethodHandles.Lookup lookup() {
return MethodHandles.lookup();
}

public static Class<?> pkgBeanClass() {
return PkgBean.class;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package blackbird.jpms.test.beans;

// Deliberately package-private, in a foreign named module: the codec
// defines in blackbird's own module context and reaches the members only
// through handles databind's fixAccess made accessible (see
// JpmsPackagePrivateTest).
class PkgBean {
private int count;
private String name;

public int getCount() { return count; }
public void setCount(int v) { count = v; }
public String getName() { return name; }
public void setName(String v) { name = v; }
}
15 changes: 15 additions & 0 deletions blackbird-jpms-tests/src/main/java/module-info.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// A foreign named module from Blackbird's point of view: it requires the
// blackbird module and holds non-public beans plus a lookup of its own, the
// exact arrangement a modular application has.
module blackbird.jpms.test
{
requires tools.jackson.databind;
requires tools.jackson.module.blackbird;

exports blackbird.jpms.test.beans;
// What a modular application must do for stock databind to construct
// non-public beans reflectively - and the ONLY requirement blackbird
// adds: none. Deliberately NOT opened to blackbird: the accelerated path
// rides databind's fixAccess through unreflected constant handles.
opens blackbird.jpms.test.beans to tools.jackson.databind;
}
Loading
Loading