Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
105 changes: 105 additions & 0 deletions afterburner-tests/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# 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.
- 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`.

## 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)
150 changes: 150 additions & 0 deletions afterburner-tests/pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
<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.2.0-SNAPSHOT</version>
</parent>

<artifactId>jackson-module-afterburner-tests</artifactId>
<name>Jackson module: Afterburner integration tests</name>
<packaging>jar</packaging>

<description>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.</description>

<dependencies>
<dependency>
<groupId>tools.jackson.module</groupId>
<artifactId>jackson-module-afterburner</artifactId>
<version>${project.version}</version>
</dependency>
</dependencies>

<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 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. -->
<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>
<!-- Felix bundle plugin: the parent POM binds two explicit executions
(`bundle-manifest` and `default-install`) so we override both by id. -->
<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>
<!-- CycloneDX SBOM plugin has a plugin-level `skip` flag, which disables
it independently of execution id merging. More robust than binding a
phase-override to an unnamed parent execution. -->
<plugin>
<groupId>org.cyclonedx</groupId>
<artifactId>cyclonedx-maven-plugin</artifactId>
<configuration>
<skip>true</skip>
</configuration>
</plugin>
<!-- Gradle module metadata plugin has no `skip` parameter, so we unbind
the parent's unnamed execution via id-based merge. Maven treats
`default` as the id for unnamed executions when merging child POM
overrides. Verified in the effective POM. -->
<plugin>
<groupId>org.gradlex</groupId>
<artifactId>gradle-module-metadata-maven-plugin</artifactId>
<executions>
<execution>
<id>default</id>
<phase>none</phase>
</execution>
</executions>
</plugin>
<!-- Jacoco report analyzes 0 classes in this module (no main sources) and
only produces log noise. Unbind the `report` execution. Leave
`prepare-agent` alone: it is silent and sets the surefire agent which
is harmless even when no main classes exist. -->
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<executions>
<execution>
<id>report</id>
<phase>none</phase>
</execution>
</executions>
</plugin>
<!-- Force surefire to classpath mode: this module has no module-info.java,
so tests must run on the classpath (unnamed module) to keep test POJO
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. -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<useModulePath>false</useModulePath>
<argLine>@{argLine} --add-opens java.base/java.lang=ALL-UNNAMED</argLine>
</configuration>
</plugin>
</plugins>
</build>
</project>
Loading