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
159 changes: 159 additions & 0 deletions .github/verify-bytecode-version.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
#!/usr/bin/env bash

# SPDX-FileCopyrightText: 2026 Bernard Ladenthin <bernard.ladenthin@gmail.com>
#
# SPDX-License-Identifier: MIT OR Apache-2.0

# Cross-repo shared script — kept BYTE-IDENTICAL in java-llama.cpp, srcmorph,
# BitcoinAddressFinder and streambuffer (sync any edit to all four, and to the checksum table in
# workspace/crossrepostatus.md). Fails when a built jar contains a class file newer than the Java
# release the artifact claims to support.
#
# Why: `maven.compiler.release` governs only the code WE compile. A dependency compiled for a newer
# Java lands in the jar untouched, and nothing in a normal build objects. The failure surfaces at a
# consumer's JVM as UnsupportedClassVersionError, which is the worst possible place to find it.
# This has happened twice here: checker-qual 4.x (Java 11 bytecode, and its annotations are
# @Retention(RUNTIME), so anything reflecting over an annotated element loads them), and
# logback-classic from 1.4.0 on, whose LogbackServiceProvider SLF4J's ServiceLoader loads at
# startup — a guaranteed crash rather than a latent one.
#
# Scan the BUILT ARTIFACT, not a resolved classpath: an uber jar is what a user actually runs, and
# `dependency:build-classpath` answers a different question (and answers it with an empty file when
# it fails, which reads as a pass).
#
# Usage: verify-bytecode-version.sh --max-major <N> [--allow <pattern>]... <path>...
# --max-major <N> highest class-file major version a consumer JVM may be asked to load.
# 52 = Java 8, 55 = Java 11, 61 = Java 17, 65 = Java 21.
# Pass it from the pipeline so the value lives next to the release it belongs
# to, instead of being duplicated here per repo.
# --allow <pattern> repeatable, optional. Glob matched against "<jar-basename>:<entry-path>", so
# it can waive a whole jar (`--allow 'foo-*.jar:*'`) or a single entry
# (`--allow '*:com/example/Legacy.class'`). Use sparingly and say why in the
# workflow: every entry here is a hole in the guarantee, and a hole nobody
# revisits is how a gate stops gating.
# <path>... jars, and/or directories searched recursively for *.jar.
#
# ALWAYS skipped, not configurable — a classpath JVM never loads these, so a high version in them
# is not a defect and waiving them per-repo would only invite blanket exceptions:
# * module-info.class (any directory) — read only in module mode, and Java 8 has none
# * META-INF/versions/** — multi-release overlays, invisible below their own release
#
# Exit codes: 0 clean · 1 violations found · 2 nothing to scan / bad usage. 2 matters as much as 1:
# a run that scanned no jars must never be reported as a pass.

set -euo pipefail

MAX_MAJOR=""
ALLOW=()
PATHS=()

fail_usage() {
echo "::error::$*" >&2
echo "usage: verify-bytecode-version.sh --max-major <N> [--allow <pattern>]... <path>..." >&2
exit 2
}

while [ $# -gt 0 ]; do
case "$1" in
--max-major) [ $# -ge 2 ] || fail_usage "--max-major needs a value"; MAX_MAJOR="$2"; shift 2 ;;
--allow) [ $# -ge 2 ] || fail_usage "--allow needs a value"; ALLOW+=("$2"); shift 2 ;;
--) shift; while [ $# -gt 0 ]; do PATHS+=("$1"); shift; done ;;
-*) fail_usage "unknown option '$1'" ;;
*) PATHS+=("$1"); shift ;;
esac
done

[ -n "$MAX_MAJOR" ] || fail_usage "--max-major is required"
case "$MAX_MAJOR" in ''|*[!0-9]*) fail_usage "--max-major must be a number, got '$MAX_MAJOR'" ;; esac
[ "${#PATHS[@]}" -gt 0 ] || fail_usage "at least one jar or directory is required"

for p in "${PATHS[@]}"; do
[ -e "$p" ] || fail_usage "path '$p' does not exist"
done

command -v python3 >/dev/null 2>&1 || fail_usage "python3 is required to read class-file headers"

# The scan itself: one pass per jar, reading the 8-byte class-file header of every entry. Kept in
# python because the alternative (unzip -p per entry) spawns a process per class — thousands for a
# fat jar — and because a zip reader must not be reimplemented in shell.
python3 - "$MAX_MAJOR" "${#ALLOW[@]}" "${ALLOW[@]}" "${PATHS[@]}" <<'PYTHON'
import fnmatch, os, sys, zipfile

max_major = int(sys.argv[1])
n_allow = int(sys.argv[2])
allow = sys.argv[3:3 + n_allow]
paths = sys.argv[3 + n_allow:]

jars = []
for p in paths:
if os.path.isdir(p):
for root, _dirs, files in os.walk(p):
jars.extend(os.path.join(root, f) for f in files if f.endswith(".jar"))
elif p.endswith(".jar"):
jars.append(p)
jars = sorted(set(jars))

# An empty scan is a broken measurement, never a pass. A glob that matched nothing, a download step
# that silently produced no artifact, a renamed output directory: all of them yield "0 violations"
# from a scanner that just shrugs, and that is indistinguishable from a clean run.
if not jars:
print(f"::error::no jars found under: {', '.join(paths)} -- refusing to report a pass", file=sys.stderr)
sys.exit(2)
Comment on lines +99 to +101

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Excellent defensive design: Exit code 2 on empty scan prevents a critical failure mode. A failed build step that silently produces no artifacts would otherwise read as a pass. This catches glob-match failures, renamed output directories, and download step regressions.

Per the PR description: the first version of this check reported a clean pass over a directory a failed build had left empty — this guard prevents that exact bug."


def skipped_always(entry):
# A plain classpath JVM never loads either of these, at any Java level.
return (entry == "module-info.class"
or entry.endswith("/module-info.class")
or entry.startswith("META-INF/versions/"))

violations = []
waived = 0
scanned = 0

for jar in jars:
base = os.path.basename(jar)
try:
zf = zipfile.ZipFile(jar)
except Exception as exc: # noqa: BLE001 - report, do not crash
print(f"::error::cannot read '{jar}': {exc}", file=sys.stderr)
sys.exit(2)
with zf:
for entry in zf.namelist():
if not entry.endswith(".class") or skipped_always(entry):
continue
try:
with zf.open(entry) as handle:
head = handle.read(8)
except Exception: # noqa: BLE001 - unreadable entry
continue
if len(head) < 8 or head[:4] != b"\xca\xfe\xba\xbe":
continue
scanned += 1
major = int.from_bytes(head[6:8], "big")
Comment on lines +129 to +132

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct class file format parsing:

  • Bytes 0-3: Magic number 0xCAFEBABE
  • Bytes 4-5: Minor version (skipped here)
  • Bytes 6-7: Major version (class file format version)

Major version mapping: 52=Java 8, 55=Java 11, 61=Java 17, 65=Java 21. The calculation major - 44 in the error message is correct per the Java spec.

This efficient approach reads only the 8-byte header per class rather than spawning unzip per entry—important for fat jars with thousands of classes.

if major <= max_major:
continue
key = f"{base}:{entry}"
if any(fnmatch.fnmatch(key, pattern) for pattern in allow):
waived += 1
continue
violations.append((base, entry, major))

# One line per offending jar, naming an example entry: a full listing of a fat jar's thousands of
# classes buries the answer, and the jar is the unit somebody acts on.
by_jar = {}
for base, entry, major in violations:
prev = by_jar.get(base)
if prev is None or major > prev[1]:
by_jar[base] = (entry, major)

for base in sorted(by_jar):
entry, major = by_jar[base]
print(f"::error::{base}: class-file major {major} (Java {major - 44}) exceeds the "
f"allowed {max_major} (Java {max_major - 44}) -- e.g. {entry}")

print(f"scanned {scanned} class file(s) in {len(jars)} jar(s); "
f"{len(by_jar)} jar(s) over major {max_major}"
+ (f"; {waived} entr(y/ies) waived by --allow" if waived else ""))

sys.exit(1 if violations else 0)
PYTHON
14 changes: 14 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3139,6 +3139,15 @@ jobs:
# backends `cuda-windows` / `vulkan-windows` / `opencl-windows`. The default JAR's
# Windows natives are the Ninja `*-libraries` merged into src/main/resources/ above.
run: mvn --batch-mode --no-transfer-progress -P release,cuda,vulkan-linux,vulkan-linux-aarch64,opencl-android,windows-msvc,cuda-windows,vulkan-windows,opencl-windows,rocm-linux,rocm-windows,sycl-fp16-linux,sycl-fp32-linux,sycl-windows,opencl-windows-aarch64,openvino-linux,openvino-windows,assembly -Dmaven.test.skip=true -Dgpg.skip=true package
# Class-file floor, checked on every jar this job just built (all 16 classifier
# jars plus the default fat jar). Production code targets Java 8, so anything a
# consumer's JVM can load must be major 52 or lower: a single Java 11 class kills
# the process with UnsupportedClassVersionError before any of our code runs, which
# is exactly what shipped when logback's LogbackServiceProvider was the binding.
# module-info.class and META-INF/versions/** are skipped unconditionally because a
# classpath JVM never loads them. Kept byte-identical across all four sibling repos.
- name: Verify Java 8 bytecode (no class newer than major 52)
run: .github/verify-bytecode-version.sh --max-major 52 llama/target
- name: Upload JARs
uses: actions/upload-artifact@v7
with:
Expand Down Expand Up @@ -3223,6 +3232,11 @@ jobs:
with:
distribution: 'temurin'
java-version: ${{ env.JAVA_VERSION }}
# Same floor, re-checked on the ASSEMBLED release asset rather than the module
# jars the `package` job verified: package-fatjars rewrites the zip (backend native
# trees + the jllama-backends.txt manifest), and this is the artifact users download.
- name: Verify Java 8 bytecode (no class newer than major 52)
run: .github/verify-bytecode-version.sh --max-major 52 fatjars
- name: Run fat-jar server smoke test
run: .github/smoke-test-fatjar.sh fatjars 'llama-*-all-linux-x86-64-jar-with-dependencies.jar' "models/${DRAFT_MODEL_NAME}"
- name: Upload server logs
Expand Down
39 changes: 33 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1633,12 +1633,39 @@ easy to undo by accident:
only in 1.5.x/1.6.x with no backport, so it is not an option either. `slf4j-simple` is six classes
from the same release train as `slf4j-api`, with no configuration or socket layer for a CVE to
live in. Configure it with a classpath `simplelogger.properties` or `-Dorg.slf4j.simpleLogger.*`.
- **`checker.qual.version` (3.55.1) is a separate property from `checker.version` (the build-time
processor).** checker-qual 4.x is major 55, its annotations are `@Retention(RUNTIME)`, and anything
reflecting over an annotated element (Jackson does) loads them. `<optional>true</optional>` keeps
it out of consumers' transitive graph but **not** out of the fat jar — `jar-with-dependencies`
filters on scope only — so the version pin is what protects the shipped artifact. Never collapse
the two properties back into one: the processor runs on the CI JDK and must stay current.
- **`checker-qual` is `provided` scope, not a pinned old version.** Its annotations are major 55
from 4.0.0 on and `@Retention(RUNTIME)`, so anything reflecting over an annotated element (Jackson
does) loads them and a Java 8 JVM throws `UnsupportedClassVersionError`. **Pinning the shipped copy
to the last Java 8 line (3.55.1) does not work** — that was shipped in #411 and broke `main`
outright: the Nullness Checker resolves its own qualifiers through javac's symbol table, i.e. the
*compile classpath*, so a 3.x checker-qual under the 4.x processor fails every build with
`Could not load type: org.checkerframework.framework.qual.DoesNotUnrefineReceiver`. Processor and
qualifiers must share a major version. `provided` satisfies both constraints: 4.2.2 on the compile
classpath where the checker needs it, and excluded from consumers' graph **and** from the fat jar
(`jar-with-dependencies` takes scope `runtime`), so no checker-qual class of any version ships.
`<optional>true</optional>` would not have been enough on its own — that descriptor filters on
scope only. Safe because no source imports `org.checkerframework`.

**The gate: `.github/verify-bytecode-version.sh`.** Kept **byte-identical** across java-llama.cpp /
BitcoinAddressFinder / streambuffer / srcmorph (checksum table in `workspace/crossrepostatus.md`).
It opens every `.class` in every jar it is given and fails on any whose class-file major version
exceeds `--max-major`:

```bash
.github/verify-bytecode-version.sh --max-major 52 [--allow '<jar>:<entry>']... <jar-or-dir>...
Comment on lines +1640 to +1655

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Documentation is excellent: This section clearly explains:

  1. Why pinning to 3.55.1 doesn't work (processor and qualifiers must match major versions)
  2. Why provided scope is the right solution (compile-time only, excluded from runtime and fat jars)
  3. Why the issue is important (caught twice already—logback 1.4.0+ and checker-qual 4.x)
  4. How the gate prevents future occurrences

This is exactly the kind of documentation that helps prevent the next person from undoing the fix by trying the obvious (but incorrect) alternative.

```

Paths may be jars or directories (searched recursively for `*.jar`), so one invocation covers a whole
artifact set — here all 16 classifier jars plus every `all-<os>-<arch>` fat jar. `module-info.class`
and `META-INF/versions/**` are skipped unconditionally: a classpath JVM never loads either, which is
why a `release 9` `module-info` is fine. `--allow` is a repeatable glob matched against
`<jar-basename>:<entry-path>` for anything else that must be tolerated. Exit codes: 0 clean,
1 violations, **2 nothing to scan** (an empty input is a failure, never a pass — the first version of
this check reported a clean pass over a directory a failed build had left empty).

It runs twice: in the `package` job over `llama/target` (every classifier jar plus the default fat
jar, as early as they exist), and again in `smoke-fatjar-linux` over the downloaded `fatjars/` —
`package-fatjars` rewrites those zips, and they are the artifacts users actually download.

**Surefire excludes `org.slf4j:slf4j-simple` from the test classpath** (`classpathDependencyExcludes`).
Runtime scope is on the test classpath too, and LogCaptor (test scope) requires logback specifically —
Expand Down
43 changes: 25 additions & 18 deletions llama/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -59,14 +59,10 @@ SPDX-License-Identifier: MIT
<lombok.version>1.18.46</lombok.version>
<errorprone.version>2.50.0</errorprone.version>
<nullaway.version>0.14.0</nullaway.version>
<!-- 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 Framework: the processor AND the checker-qual qualifiers it resolves.
Both run on the build JDK and neither ships (checker-qual is provided scope), so
this tracks the newest release. -->
<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>
<reactor.version>3.8.7</reactor.version>
<slf4j.version>2.0.18</slf4j.version>
Expand Down Expand Up @@ -190,20 +186,31 @@ SPDX-License-Identifier: MIT
<artifactId>jspecify</artifactId>
<version>${jspecify.version}</version>
</dependency>
<!-- Pinned to the newest Java 8 line on purpose. checker-qual 4.x is Java 11
bytecode (class-file major 55) and this artifact targets Java 8; its
annotations are @Retention(RUNTIME), so anything reflecting over an
annotated element (Jackson does) loads them and a Java 8 JVM then throws
UnsatisfiedClassVersionError. Marking it optional keeps it out of
consumers' transitive graph but NOT out of the fat jar; the
jar-with-dependencies descriptor filters on scope only, so the version
pin is the part that actually protects the shipped artifact.
3.55.1 is the last release whose classes are major 52; the break is at 4.0.0. -->
<!-- COMPILE-ONLY, and that is the whole point. checker-qual 4.x is Java 11
bytecode (class-file major 55) while this artifact targets Java 8, and its
annotations are @Retention(RUNTIME), so anything reflecting over an annotated
element (Jackson does) loads them and a Java 8 JVM then throws
UnsupportedClassVersionError.

The obvious fix (pinning the shipped copy to the last Java 8 line, 3.55.1) does
not work and was reverted: the Nullness Checker resolves its own qualifiers
through javac's symbol table, i.e. the COMPILE CLASSPATH, so a 3.x checker-qual
under a 4.x processor fails the build outright with "Could not load type:
org.checkerframework.framework.qual.DoesNotUnrefineReceiver". Processor and
qualifiers must share a major version.

provided scope resolves both constraints at once: 4.2.2 is on the compile
classpath where the checker needs it, and provided is excluded from consumers'
transitive graph AND from the fat jar (jar-with-dependencies takes scope
runtime), so no checker-qual class of any version reaches a consumer's JVM.
Safe because no source in this module imports org.checkerframework: the
annotations exist for the processor, not for our code.
.github/verify-bytecode-version.sh enforces the resulting class-file floor. -->
<dependency>
<groupId>org.checkerframework</groupId>
<artifactId>checker-qual</artifactId>
<version>${checker.qual.version}</version>
<optional>true</optional>
<version>${checker.version}</version>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Scope change is key: provided scope accomplishes what a version pin alone cannot:

  • Compile classpath: 4.2.2 is present (so the Nullness Checker finds its qualifiers and the build succeeds)
  • Runtime classpath: excluded (so no checker-qual classes reach a consumer's JVM)
  • Fat jar: excluded (jar-with-dependencies uses scope runtime, so provided is filtered out)

This is strictly better than shipping an old 3.55.1 copy, and it's the only way to keep both processor and qualifiers on the same major version without breaking consumers.

<scope>provided</scope>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
Expand Down
Loading