fix!: ship a Java 8 loadable SLF4J binding and checker-qual - #411
Conversation
This artifact targets Java 8, but three of its runtime jars were Java 11 bytecode (class-file major 55), measured on the resolved runtime classpath: logback-classic 1.6.3, logback-core 1.6.3 and checker-qual 4.2.2. logback is the hard failure. SLF4J's ServiceLoader loads LogbackServiceProvider at JVM startup, so a Java 8 consumer got UnsupportedClassVersionError before any library code ran. The obvious fix -- drop to logback's Java 8 line -- is worse than the disease: 1.3.16 (2025-10-29) is its last release, CVE-2026-1225, CVE-2026-9828 and CVE-2026-10532 were fixed only in 1.5.x, and CVE-2026-19880 only in 1.6.3, which is Java 11 bytecode and therefore unreachable from here. That is a permanent unpatchable state, not a pin. slf4j-simple instead: six classes, same release train as slf4j-api, and no configuration parser, socket server or deserialization -- the subsystems essentially every logback CVE lives in. Consumers who want logback exclude it and declare their own binding, which is what the SLF4J split is for. checker-qual is the quieter one. Its annotations are @retention(RUNTIME), not CLASS, so anything reflecting over an annotated element loads them and a Java 8 JVM throws. <optional>true</optional> keeps it out of consumers' transitive graph but NOT out of the fat jar, because jar-with-dependencies filters on scope only -- so the pin to 3.55.1 (last major-52 release; the break is at 4.0.0) is what actually protects the shipped artifact. The build-time Checker Framework processor is decoupled onto its own property and stays at 4.2.2; collapsing them breaks the build, since 3.55.1 has no matching processor jar. Surefire now excludes slf4j-simple from the test classpath. Runtime scope is on the test classpath too, and LogCaptor requires logback specifically -- with both providers present, 7 tests failed with 'SLF4J Logger implementation should be of the type [ch.qos.logback.classic.Logger]'. Found by running them, not by reasoning about them. Verified: runtime classpath scanned for class-file majors above 52 -- 3 jars before, 0 after; the four LogCaptor test classes 74/74 green; spotbugs:check 0 bugs; spotless:check clean. BREAKING CHANGE: logback.xml is no longer read. Configure logging with a classpath simplelogger.properties or -Dorg.slf4j.simpleLogger.* system properties, or exclude org.slf4j:slf4j-simple and declare your own binding. With no configuration, output is quieter than before (logback defaulted to DEBUG, slf4j-simple defaults to INFO). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AnNYn8W1xuVxVJtyL34GyH
| <!-- Build-time Checker Framework processor. Runs on the CI JDK, never ships, so it | ||
| tracks the newest release. Deliberately NOT the same property as the annotations | ||
| below: those are shipped and must stay Java 8 bytecode. --> | ||
| <checker.version>4.2.2</checker.version> | ||
| <!-- Shipped checker-qual annotations. Last release whose classes are class-file | ||
| major 52; 4.0.0 moved the line to Java 11. See the dependency for why the pin, | ||
| not just the optional flag, is what protects the artifact. --> | ||
| <checker.qual.version>3.55.1</checker.qual.version> |
There was a problem hiding this comment.
Excellent separation of concerns here: checker.version (4.2.2, build-time only) stays current while checker.qual.version (3.55.1, shipped) is pinned to Java 8 bytecode. The comments clearly explain the why. ✓\n\nOne note: The comment at line 134 in dependencyManagement now says "we declare ${logback.version} directly" but logback-classic is no longer a direct dependency — it's now only a transitive from logcaptor (test scope). Consider updating that comment to clarify the transitive relationship and that the shipped artifact uses slf4j-simple instead."
…umers Replacing logback left the runnable jar with slf4j-simple's built-in defaults: WARN-and-above, to stderr, no timestamps. For a server whose output people pipe and correlate, that is a downgrade from what logback emitted. Adding src/main/resources/simplelogger.properties would fix the fat jar and break the library: it would be published inside llama.jar, and slf4j-simple reads whichever simplelogger.properties the classloader finds first -- so a consumer with their own file would get a coin flip decided by classpath order. A library does not get to make that choice for its consumers; an application does, and the fat jar is the application. So the file lives in src/main/assembly-resources/ and is added by a project descriptor (src/assembly/fat-jar.xml) instead of the predefined jar-with-dependencies ref. The descriptor's dependencySet is a verbatim copy of the predefined one -- outputDirectory /, useProjectArtifact true, unpack true, scope runtime -- and its comment says so, because the only reason the file exists is the fileSet below it. Defaults chosen to match what the logback binding used to produce: INFO, timestamps, thread and short logger name; stdout rather than slf4j-simple's stderr, so the server's own output shares a stream with everything else. All overridable with -Dorg.slf4j.simpleLogger.<key>. Verified by inspecting both built artifacts, not by reasoning about the descriptor: the fat jar contains simplelogger.properties and the slf4j-simple classes; the library jar contains neither; both are class-file major 52 throughout; no logback classes in either. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AnNYn8W1xuVxVJtyL34GyH
Review SummaryThis PR correctly fixes a hard failure where Java 8 consumers encounter UnsupportedClassVersionError at startup. Strengths:
Code quality: No bugs found. Assembly descriptor and properties files are structurally correct. Status: Local tests 74/74 passing. CI validation pending per PR checklist. Recommend merge once CI validates green. |
| <artifactId>maven-assembly-plugin</artifactId> | ||
| <configuration> | ||
| <descriptorRefs> | ||
| <descriptorRef>jar-with-dependencies</descriptorRef> | ||
| </descriptorRefs> | ||
| <!-- Our own descriptor, not the predefined jar-with-dependencies ref: it is | ||
| a verbatim copy of that one plus simplelogger.properties, which must | ||
| reach the runnable jar without being published in the library jar. --> | ||
| <descriptors> |
There was a problem hiding this comment.
Key design decision: Using a custom descriptor instead of the predefined jar-with-dependencies allows simplelogger.properties to be added to the fat jar only, not the library jar. This prevents the library from imposing logging configuration on its consumers — a critical distinction for library code. The custom descriptor is otherwise identical to the predefined one.
| <!-- Build-time Checker Framework processor. Runs on the CI JDK, never ships, so it | ||
| tracks the newest release. Deliberately NOT the same property as the annotations | ||
| below: those are shipped and must stay Java 8 bytecode. --> | ||
| <checker.version>4.2.2</checker.version> | ||
| <!-- Shipped checker-qual annotations. Last release whose classes are class-file | ||
| major 52; 4.0.0 moved the line to Java 11. See the dependency for why the pin, | ||
| not just the optional flag, is what protects the artifact. --> | ||
| <checker.qual.version>3.55.1</checker.qual.version> | ||
| <jackson.version>2.22.2</jackson.version> |
There was a problem hiding this comment.
Important split: checker.version (4.2.2) is for the build-time annotation processor, which runs on the CI JDK and never ships. checker.qual.version (3.55.1) is for the shipped annotations — it must stay on the last major-52 (Java 8) release because @Retention(RUNTIME) annotations are loaded by any reflection (Jackson does this during introspection). The <optional>true</optional> flag alone is insufficient because jar-with-dependencies filters on scope only, not the optional flag. Both the version pin AND the optional flag together protect the artifact.
| <configuration> | ||
| <!-- -XX:+EnableDynamicAgentLoading: silences the JDK 21 byte-buddy self-attach agent warning that intermittently corrupts Surefire's fork channel ("Corrupted channel ..." / bogus "timeout in the fork"). See workspace policy ci-test-diagnostics.md section 2.1: https://github.com/bernardladenthin/workspace/blob/main/policies/ci-test-diagnostics.md - root cause: https://github.com/raphw/byte-buddy/issues/1639 --> | ||
| <argLine>@{argLine} -Xmx2g -XX:ErrorFile=hs_err_pid%p.log -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=. -XX:+EnableDynamicAgentLoading</argLine> | ||
| <!-- | ||
| Keep the shipped SLF4J binding OFF the test classpath. | ||
|
|
||
| slf4j-simple is a runtime-scope dependency, and runtime scope is part of | ||
| the test classpath too. LogCaptor (io.github.hakky54, test scope) brings | ||
| logback, so both providers would be present, SLF4J would pick one | ||
| arbitrarily, and LogCaptor fails outright when it does not get logback: | ||
| "SLF4J Logger implementation should be of the type | ||
| [ch.qos.logback.classic.Logger] but found [org.slf4j.simple.SimpleLogger]" | ||
| (7 tests across LoggingSmokeTest, TimingsLoggerTest, ChatResponseParserTest | ||
| and CompletionResponseParserTest). | ||
|
|
||
| Excluding it here leaves logback as the sole provider during tests and does | ||
| not touch the artifact: the shipped jar and the fat jar still carry | ||
| slf4j-simple. The tests assert what our code logs, which is binding-agnostic; | ||
| which binding renders it is not what they are about. | ||
| --> | ||
| <classpathDependencyExcludes> | ||
| <classpathDependencyExclude>org.slf4j:slf4j-simple</classpathDependencyExclude> |
There was a problem hiding this comment.
Test-specific fix: Runtime-scoped dependencies are included on the test classpath. With both slf4j-simple and logback present, SLF4J picks a provider arbitrarily, causing LogCaptor (which requires logback specifically) to fail with "SLF4J Logger implementation should be of the type [ch.qos.logback.classic.Logger]". Excluding slf4j-simple here leaves logback as the sole provider in tests, while the shipped artifacts (both library and fat jar) still correctly carry slf4j-simple. Tests validate behavior, not binding choice.
The artifact says Java 8 and ships Java 11 bytecode
llamacompiles withrelease 8, but three jars on its runtime classpath are class-file major 55. Measured, not assumed — every.classin every runtime jar, excludingmodule-info.classandMETA-INF/versions/**(a Java 8 classpath never reads those):After this PR: 0 jars above major 52.
logback is the hard failure, and downgrading is worse than the disease
SLF4J's
ServiceLoaderloadsLogbackServiceProviderat JVM startup, so a Java 8 consumer getsUnsupportedClassVersionErrorbefore a single line of library code runs.The obvious fix — drop to logback's Java 8 line — does not survive scrutiny:
1.3.16 (2025-10-29) is the last release of that line; five releases have shipped in 1.5/1.6 since, several security-driven, with no backports. Pinning it would trade a startup crash for permanent unpatchability, and Dependabot/osv-scanner would flag it forever with no upgrade path.
slf4j-simpleinstead: six classes, same release train as theslf4j-apialready depended on (so they cannot drift apart), and — the part that matters — no configuration parser, socket server or deserialization. Those are the subsystems essentially every logback CVE lives in (Janino expression evaluation,HardenedObjectInputStream,SaxEventRecorder,SocketReceiver); a binding without them cannot have that class of defect.checker-qual: the premise I started from was wrong
I assumed its annotations were
@Retention(CLASS)— "never loaded, so the version is irrelevant and<optional>true</optional>is enough". Checked instead of believed: 364 of them areRUNTIME, none areCLASS. Anything reflecting over an annotated element loads them, and Jackson does exactly that during introspection.And
<optional>true</optional>does not remove it from the fat jar — thejar-with-dependenciesdescriptor filters on<scope>only, verified against the descriptor shipped insidemaven-assembly-plugin-3.8.0.jar. So the optional flag protects consumers' transitive graph, and the version pin is what protects the shipped artifact. Both are applied.checker.versionis split into two properties.${checker.version}also drove the build-time Checker Framework processor inannotationProcessorPath; downgrading that broke resolution outright (org.checkerframework:checker:jar:3.55.1does not exist). The processor runs on the CI JDK and never ships, so it stays at 4.2.2 under its own property; only the shipped annotations move to 3.55.1 (the last major-52 release; the break is exactly at 4.0.0).One breakage found by running the tests, not by reasoning about them
Runtime scope is on the test classpath too, so slf4j-simple joined the logback that LogCaptor brings. Two providers, SLF4J picks one arbitrarily, and LogCaptor fails outright:
7 tests across
LoggingSmokeTest,TimingsLoggerTest,ChatResponseParserTestandCompletionResponseParserTest. Fixed with a surefireclasspathDependencyExcludesonorg.slf4j:slf4j-simple, which leaves logback the sole provider in tests and does not touch the artifact. The tests assert what our code logs, which is binding-agnostic.Test plan
mvn -f llama/pom.xml -DskipTests compile spotbugs:check—BugInstance size is 0(the exact command theCode stylejob runs)mvn -f llama/pom.xml spotless:check— exit 0pom.xmlverified well-formed by a strict XML parser after every editRelated issues / PRs
The same two problems exist in
srcmorphandstreambuffer(both Java 8);BitcoinAddressFinderis Java 21 and unaffected. A sharedverify-bytecode-version.shgate across all four repos follows separately — this class of defect is invisible to compilation, to the tests, and to every gate we currently run.Checklist
CONTRIBUTING.mdandCODE_OF_CONDUCT.mdfix!+BREAKING CHANGEtrailer)🤖 Generated with Claude Code
https://claude.ai/code/session_01AnNYn8W1xuVxVJtyL34GyH
Generated by Claude Code