From a91e569e88b7faeac1a265b4af33ae92753d7dd2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 21:43:52 +0000 Subject: [PATCH 1/2] fix(build): make checker-qual provided instead of pinning it to 3.55.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit main does not compile. #411 pinned the shipped checker-qual to 3.55.1, the last release whose classes are Java 8 bytecode, so that a Java 8 consumer could not hit UnsupportedClassVersionError on annotations Jackson loads reflectively. The reasoning about the artifact was right; the mechanism was wrong. The Nullness Checker resolves its own qualifiers through javac's symbol table — the COMPILE CLASSPATH — not through the annotation-processor path. Under the 4.2.2 processor a 3.x checker-qual is missing org.checkerframework.framework.qual.DoesNotUnrefineReceiver, and every build dies with "Configuration problem! Could not load type: ...". Processor and qualifiers must share a major version. Reproduced both ways locally: main fails, main with -Dchecker.qual.version=4.2.2 succeeds. The publish run for #411 was cancelled 26s in, so nothing caught it. provided scope satisfies both constraints at once instead of trading one for the other. 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, which is strictly better than shipping an old one. true could not have done this on its own: that descriptor filters on scope only, which is exactly the trap #411 documented. Safe because no source in this module imports org.checkerframework; the annotations exist for the processor, not for our code. Verified on the built artifacts: 0 checkerframework entries in the fat jar, mvn verify green (1474 tests), and the reactor's langchain4j and kotlin modules still build. The now-redundant checker.qual.version property is removed and the rationale rewritten in place, so the next reader sees why the obvious fix is not the fix. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AnNYn8W1xuVxVJtyL34GyH --- CLAUDE.md | 18 ++++++++++++------ llama/pom.xml | 43 +++++++++++++++++++++++++------------------ 2 files changed, 37 insertions(+), 24 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ed50871c..82fe1711 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1633,12 +1633,18 @@ 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. `true` 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. + `true` would not have been enough on its own — that descriptor filters on + scope only. Safe because no source imports `org.checkerframework`. **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 — diff --git a/llama/pom.xml b/llama/pom.xml index 6b0a4d8d..4b77c3bd 100644 --- a/llama/pom.xml +++ b/llama/pom.xml @@ -59,14 +59,10 @@ SPDX-License-Identifier: MIT 1.18.46 2.50.0 0.14.0 - + 4.2.2 - - 3.55.1 2.22.2 3.8.7 2.0.18 @@ -190,20 +186,31 @@ SPDX-License-Identifier: MIT jspecify ${jspecify.version} - + org.checkerframework checker-qual - ${checker.qual.version} - true + ${checker.version} + provided com.fasterxml.jackson.core From feaa868056fb2bebb6e5d0f2146cc81dadc961c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 21:44:35 +0000 Subject: [PATCH 2/2] ci: gate every built jar on the Java 8 class-file floor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `release 8` governs only the code we compile. A dependency built 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 repo has now hit it twice — logback 1.4.0+, whose LogbackServiceProvider SLF4J's ServiceLoader loads at startup, and checker-qual 4.x, whose @Retention(RUNTIME) annotations load on any reflection over an annotated element. Both were found by a person, not by CI. Adds the cross-repo shared `.github/verify-bytecode-version.sh`, kept byte-identical in java-llama.cpp, BitcoinAddressFinder, streambuffer and srcmorph; the checksum lives in workspace/crossrepostatus.md. `--max-major` comes from the workflow so the ceiling sits next to the release it belongs to (52 here, 65 in the Java 21 sibling) instead of being duplicated per repo. It runs twice, because the two places answer different questions: * `package`, over `llama/target` — all 16 classifier jars plus the default fat jar, checked as early as they exist and before anything downstream consumes them. * `smoke-fatjar-linux`, over the downloaded `fatjars/` — package-fatjars rewrites those zips to add the backend native trees and the manifest, and they are the artifacts a user actually downloads from a release. Notes on the script's shape: * `module-info.class` and `META-INF/versions/**` are skipped unconditionally. A classpath JVM never loads either, so a high version there is not a defect and waiving them per-repo would only invite blanket exceptions. * exit 2 on an empty scan. A run that found no jars must never read as a pass — an earlier ad-hoc version of this check reported a clean pass over a directory a failed build had left empty. * paths may be jars or directories, so one invocation covers a whole artifact set rather than needing a step per classifier. Verified locally against a real `mvn -P assembly package`: 1904 classes across the six jars in llama/target, 0 over major 52. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AnNYn8W1xuVxVJtyL34GyH --- .github/verify-bytecode-version.sh | 159 +++++++++++++++++++++++++++++ .github/workflows/publish.yml | 14 +++ CLAUDE.md | 21 ++++ 3 files changed, 194 insertions(+) create mode 100755 .github/verify-bytecode-version.sh diff --git a/.github/verify-bytecode-version.sh b/.github/verify-bytecode-version.sh new file mode 100755 index 00000000..a7a39546 --- /dev/null +++ b/.github/verify-bytecode-version.sh @@ -0,0 +1,159 @@ +#!/usr/bin/env bash + +# SPDX-FileCopyrightText: 2026 Bernard Ladenthin +# +# 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 [--allow ]... ... +# --max-major 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 repeatable, optional. Glob matched against ":", 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. +# ... 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 [--allow ]... ..." >&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) + +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") + 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 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 9d8e0472..4b6a41a8 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -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: @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 82fe1711..c8634059 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1646,6 +1646,27 @@ easy to undo by accident: `true` 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 ':']... ... +``` + +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--` 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 +`:` 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 — with both providers present it fails with *"SLF4J Logger implementation should be of the type