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 ed50871c..c8634059 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. `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`. + +**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 — 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