Skip to content

Commit 5f33352

Browse files
authored
Add "afterburner-tests"; enhanced Afterburner-for-classpath tests (#347)
1 parent 0929a1c commit 5f33352

File tree

9 files changed

+852
-1
lines changed

9 files changed

+852
-1
lines changed

afterburner-tests/README.md

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
# jackson-module-afterburner-tests
2+
3+
Classpath-mode integration tests for `jackson-module-afterburner`. **This module is
4+
test-only and is never published.**
5+
6+
## Why this module exists
7+
8+
Afterburner's most valuable optimizations come from bytecode injection:
9+
`PropertyMutatorCollector`, `PropertyAccessorCollector`, and `CreatorOptimizer`
10+
generate custom mutator / accessor / instantiator classes in the *target bean's
11+
package* and install them on the bean's `ClassLoader`. This only works when the
12+
target package is not sealed.
13+
14+
In Jackson 3.x every module descriptor — including afterburner's own test module
15+
— is a named JPMS module, and the JVM automatically seals every package inside a
16+
named module. As a result, every POJO defined inside `tools.jackson.module.afterburner`
17+
test sources fails the `MyClassLoader.canAddClassInPackageOf` check, and afterburner
18+
silently falls back to plain reflection. That means **none** of afterburner's
19+
injection paths are exercised by the in-tree afterburner test suite — the optimized
20+
code path is effectively dead weight in CI even though the tests appear to pass.
21+
22+
This module closes that gap. It lives alongside the afterburner module but
23+
deliberately:
24+
25+
- has **no `module-info.java`** in either main or test sources,
26+
- is configured (`useModulePath=false`) to run its tests on the classpath,
27+
- and therefore loads its test POJOs into the **unnamed module**, whose packages
28+
are not sealed.
29+
30+
Afterburner's injection pipeline runs on those POJOs normally, and the tests here
31+
verify via reflection on databind internals that each property was actually
32+
replaced with an `OptimizedSettableBeanProperty` / `OptimizedBeanPropertyWriter`
33+
and each default-creator POJO got an `OptimizedValueInstantiator`.
34+
35+
## Do not add a `module-info.java` here
36+
37+
Adding a module descriptor would put these test POJOs back into a named module,
38+
re-seal their packages, and silently turn this whole module into dead weight —
39+
same as the in-tree afterburner tests.
40+
41+
If a future cleanup pass wants to "unify" the project layout by making every
42+
module JPMS-consistent, this one is the exception. Read this file before doing
43+
that.
44+
45+
## Do not publish this module
46+
47+
Several parent-POM plugin bindings are unbound or skipped in `pom.xml` so that
48+
`mvn install` on this module produces no jar, no SBOM, no Gradle module metadata,
49+
and no OSGi bundle. The module exists purely to run tests. If you rename an
50+
execution override in `pom.xml`, re-verify with:
51+
52+
```
53+
./mvnw help:effective-pom -pl afterburner-tests
54+
```
55+
56+
A typo in an execution id turns into silent dead config and leaves stale
57+
artifacts in `target/` rather than a build error.
58+
59+
## What is covered
60+
61+
- `PropertyMutatorCollector` — int, long, boolean, and reference-type
62+
specializations for both public-field and setter-method access
63+
(`MutatorSpecializationsTest`).
64+
- `PropertyAccessorCollector` — writer replacement on the serializer side
65+
(`SerializerInjectionTest`).
66+
- `CreatorOptimizer` — default-constructor and static-factory positive cases,
67+
plus property-based `@JsonCreator` negative case (`CreatorOptimizerTest`).
68+
- `MyClassLoader.defineClass` — transitively exercised by all of the above.
69+
70+
## What is *not* covered
71+
72+
- Private-class guard (`_classLoader != null && isPrivate(beanClass)`).
73+
- `setUseValueClassLoader(false)` toggle.
74+
- Concurrent deserializer construction.
75+
- Fallback path when afterburner correctly refuses to optimize (e.g. a bean
76+
whose package really is sealed — the in-tree afterburner tests inadvertently
77+
cover this).
78+
- GraalVM native-image disable path in `AfterburnerModule.setupModule`.
79+
80+
## Known findings from this module
81+
82+
### Afterburner parent-classloader caching silently broken on Java 9+ (#348)
83+
84+
While writing `GeneratedClassCachingTest`, we discovered that Afterburner's
85+
parent-classloader cache — the mechanism that's supposed to make two
86+
independent `ObjectMapper` instances share a single generated mutator class
87+
per POJO — does not work on Java 9+ unless the JVM is launched with
88+
`--add-opens java.base/java.lang=ALL-UNNAMED`. `MyClassLoader` reflects into
89+
`ClassLoader#findLoadedClass` and `ClassLoader#defineClass`, both protected
90+
members of `java.lang.ClassLoader`; on modern JDKs the reflective access
91+
throws `InaccessibleObjectException`, which Afterburner catches silently and
92+
falls back to defining each generated class in a fresh throwaway loader.
93+
94+
That's why this module's `pom.xml` sets
95+
`--add-opens java.base/java.lang=ALL-UNNAMED` on the surefire `argLine`.
96+
Without it, `testSameBeanAcrossMappersReusesSameMutatorClass` fails with two
97+
distinct `Class<?>` instances carrying identical fully-qualified names — the
98+
exact fingerprint of the leak. This is **not a test-environment quirk**; it
99+
affects every real Afterburner user on Java 9+. See issue #348 for the fix
100+
plan.
101+
102+
## References
103+
104+
- PR #347: rationale for the module's structure and initial coverage
105+
- Issue #348: afterburner classloader caching regression (surfaced here)

afterburner-tests/pom.xml

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
<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">
2+
<modelVersion>4.0.0</modelVersion>
3+
<parent>
4+
<groupId>tools.jackson.module</groupId>
5+
<artifactId>jackson-modules-base</artifactId>
6+
<version>3.2.0-SNAPSHOT</version>
7+
</parent>
8+
9+
<artifactId>jackson-module-afterburner-tests</artifactId>
10+
<name>Jackson module: Afterburner integration tests</name>
11+
<packaging>jar</packaging>
12+
13+
<description>Classpath-mode integration tests for jackson-module-afterburner. This module
14+
intentionally has no module-info.java so that its test POJOs live in the unnamed module, where
15+
their packages are not sealed-by-module. That lets Afterburner exercise its bytecode injection
16+
paths (PropertyMutatorCollector, CreatorOptimizer, PropertyAccessorCollector) — paths that are
17+
gated off when the target bean sits in a named JPMS module. This module is test-only and is
18+
never published.</description>
19+
20+
<dependencies>
21+
<dependency>
22+
<groupId>tools.jackson.module</groupId>
23+
<artifactId>jackson-module-afterburner</artifactId>
24+
<version>${project.version}</version>
25+
</dependency>
26+
</dependencies>
27+
28+
<build>
29+
<plugins>
30+
<!-- This module is test-only and must never produce a publishable artifact.
31+
Packaging stays `jar` so the compile + test lifecycle runs normally,
32+
but every artifact-producing / artifact-publishing plugin inherited
33+
from the parent chain is unbound here.
34+
35+
Verified via `mvn help:effective-pom -pl afterburner-tests`: every
36+
execution listed below appears in the merged model at phase=none or
37+
with skip=true. If you rename an execution here, re-verify with that
38+
command — a typo turns into silent dead config and stale artifacts
39+
in target/ rather than a build error. -->
40+
<plugin>
41+
<groupId>org.apache.maven.plugins</groupId>
42+
<artifactId>maven-jar-plugin</artifactId>
43+
<executions>
44+
<execution>
45+
<id>default-jar</id>
46+
<phase>none</phase>
47+
</execution>
48+
</executions>
49+
</plugin>
50+
<plugin>
51+
<groupId>org.apache.maven.plugins</groupId>
52+
<artifactId>maven-install-plugin</artifactId>
53+
<configuration>
54+
<skip>true</skip>
55+
</configuration>
56+
</plugin>
57+
<plugin>
58+
<groupId>org.apache.maven.plugins</groupId>
59+
<artifactId>maven-deploy-plugin</artifactId>
60+
<configuration>
61+
<skip>true</skip>
62+
</configuration>
63+
</plugin>
64+
<!-- Felix bundle plugin: the parent POM binds two explicit executions
65+
(`bundle-manifest` and `default-install`) so we override both by id. -->
66+
<plugin>
67+
<groupId>org.apache.felix</groupId>
68+
<artifactId>maven-bundle-plugin</artifactId>
69+
<executions>
70+
<execution>
71+
<id>bundle-manifest</id>
72+
<phase>none</phase>
73+
</execution>
74+
<execution>
75+
<id>default-install</id>
76+
<phase>none</phase>
77+
</execution>
78+
</executions>
79+
</plugin>
80+
<!-- CycloneDX SBOM plugin has a plugin-level `skip` flag, which disables
81+
it independently of execution id merging. More robust than binding a
82+
phase-override to an unnamed parent execution. -->
83+
<plugin>
84+
<groupId>org.cyclonedx</groupId>
85+
<artifactId>cyclonedx-maven-plugin</artifactId>
86+
<configuration>
87+
<skip>true</skip>
88+
</configuration>
89+
</plugin>
90+
<!-- Gradle module metadata plugin has no `skip` parameter, so we unbind
91+
the parent's unnamed execution via id-based merge. Maven treats
92+
`default` as the id for unnamed executions when merging child POM
93+
overrides. Verified in the effective POM. -->
94+
<plugin>
95+
<groupId>org.gradlex</groupId>
96+
<artifactId>gradle-module-metadata-maven-plugin</artifactId>
97+
<executions>
98+
<execution>
99+
<id>default</id>
100+
<phase>none</phase>
101+
</execution>
102+
</executions>
103+
</plugin>
104+
<!-- Jacoco report analyzes 0 classes in this module (no main sources) and
105+
only produces log noise. Unbind the `report` execution. Leave
106+
`prepare-agent` alone: it is silent and sets the surefire agent which
107+
is harmless even when no main classes exist. -->
108+
<plugin>
109+
<groupId>org.jacoco</groupId>
110+
<artifactId>jacoco-maven-plugin</artifactId>
111+
<executions>
112+
<execution>
113+
<id>report</id>
114+
<phase>none</phase>
115+
</execution>
116+
</executions>
117+
</plugin>
118+
<!-- Force surefire to classpath mode: this module has no module-info.java,
119+
so tests must run on the classpath (unnamed module) to keep test POJO
120+
packages unsealed.
121+
122+
The add-opens flag below is required by Afterburner itself, not by
123+
the tests. Afterburner's MyClassLoader reflectively invokes
124+
ClassLoader#findLoadedClass and ClassLoader#defineClass on the bean's
125+
parent classloader so that generated mutator classes are cached on
126+
that parent and reused across mappers. Both methods live in
127+
java.base/java.lang and on Java 17+ are only reachable when the
128+
module is opened to the unnamed module. Without this flag,
129+
Afterburner silently falls back to defining each generated class in
130+
a fresh throwaway MyClassLoader, which leaks classloaders under load
131+
(and which GeneratedClassCachingTest catches as a regression).
132+
133+
Note: reflection into databind internals from the tests themselves
134+
works without opens because this module runs on the classpath, where
135+
setAccessible(true) from the unnamed module is unrestricted.
136+
137+
Note 2: surefire's argLine is additive when jacoco's prepare-agent
138+
already set a value, so we reference @{argLine} to preserve the
139+
jacoco agent config. -->
140+
<plugin>
141+
<groupId>org.apache.maven.plugins</groupId>
142+
<artifactId>maven-surefire-plugin</artifactId>
143+
<configuration>
144+
<useModulePath>false</useModulePath>
145+
<argLine>@{argLine} --add-opens java.base/java.lang=ALL-UNNAMED</argLine>
146+
</configuration>
147+
</plugin>
148+
</plugins>
149+
</build>
150+
</project>

0 commit comments

Comments
 (0)