diff --git a/CHANGELOG.md b/CHANGELOG.md index ecdf866..2536f82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,12 +6,18 @@ All notable changes to this project will be documented in this file. ## [0.0.2] - 2026-08-27 +This entry describes the release candidate; it does not authorize or attest publication. + ### Changed - Pinned bundled native vllm.cpp to tag `v0.0.2`, commit `7020de93652ca920424a10ac5255b34810dd2f24`, moving the stable C contract from ABI 10 with 19 functions to ABI 17 with 35 functions; system libraries must match the new ABI, layouts, signatures, and exports. - Model construction now obtains native defaults and overlays only explicit Rust settings, preserving helper defaults including `block_size=32`, `max_num_seqs=32`, and `gpu_memory_utilization=0.92`. - Hardened deterministic backend configuration, binding/export/package checks, package curation, and multi-architecture Triton packaging: all six vendored AOT trees are embedded, exact-SM dispatched, and paired with portable fallback on other accepted targets. -- Raised the safe crate unpacked package limit to 512 KiB while retaining its 40-file and 128-KiB compressed limits; sys limits remain 1,400 files, 40 MiB unpacked, and 6 MiB compressed. +- Removed arbitrary package size and file-count caps for both crates. File counts, compressed sizes, regular-file payload sums, and SHA-256 hashes remain diagnostics, not release limits; exact inventories and all content, provenance, extracted-build, and downstream compatibility checks remain required. +- Contained the entire Rust logits-processor trampoline lifetime, including final capture destruction after concurrent registration teardown, while preserving callback panic reporting and panic-payload disposal. +- Pinned online GGUF metadata and body retrieval to a validated immutable commit; explicit full-SHA requests reject different metadata or offline snapshots. Validate downloads before explicitly updating requested refs, and repair hf-hub's implicit pinned-SHA ref writes on rejection without rolling back mutable aliases. Offline resolution still requires a cache ref; mutable `main` remains the default requested revision. +- Native C API fixture failures now retain the default scheduler's failing status; an alternate scheduler run cannot make the gate pass. Callback counts are not token counts, so the upstream callback-count assertion alone does not establish token loss. +- Optional ASan/UBSan and native-only TSan recipes select exact package/target test executables from Cargo JSON, reject empty suites, and require each exact TSan filter to pass one named test. Removed the stale release number from the CUDA/Vulkan conflict diagnostic. ### Added @@ -19,7 +25,7 @@ All notable changes to this project will be documented in this file. - Pre-tokenized `Engine::complete_tokens` with owned token output, optional copied completion metadata, and explicit truncation reporting. - Thread-local, exclusive `TranscriptionEngine` and `EmbeddingEngine` owners with borrowed inputs and Rust-owned transcription and row-major embedding results. - Separate `VideoEngine` checkpoint-set ownership, blocking generation parameters/results, and standalone `VideoMuxParams`/`VideoMuxArgv` composition that never executes ffmpeg. -- Model-free acceptance gates for docs, tests, ABI/layout/signature/exports, native fixtures, sanitizers, link modes, packages, downstream consumers, publish dry-run, and exact MSRV. +- Model-free acceptance gates for docs, tests, ABI/layout/signature/exports, native fixtures, sanitizers, link modes, packages, downstream consumers, publish dry-run, and exact MSRV. Focused Rust and tooling regressions cover callback destruction, loopback Hub branch movement/cache identity, default native-gate status, mixed artifacts, missing filters, and filesystem-independent payload accounting. These gate definitions are not evidence of an exact-candidate pass. ### Compatibility diff --git a/Justfile b/Justfile index 7a416e9..19eddf6 100644 --- a/Justfile +++ b/Justfile @@ -720,21 +720,13 @@ package-test: check_package_links "$safe_root" README.md examples/README.md sys_package_size=$(stat -c '%s' "$sys_package") - sys_unpacked_size=$(du -sb "$sys_root" | cut -f1) - sys_regular_file_bytes=$(find "$sys_root" -type f -printf '%s\n' \ - | awk '{ total += $1 } END { print total + 0 }') + sys_payload_size=$(just --justfile {{ quote(root + "/Justfile") }} _package-payload "$sys_package") sys_package_sha256=$(sha256sum "$sys_package" | awk '{ print $1 }') sys_entry_count=$(wc -l < "$sys_list") safe_package_size=$(stat -c '%s' "$safe_package") - safe_unpacked_size=$(du -sb "$safe_root" | cut -f1) + safe_payload_size=$(just --justfile {{ quote(root + "/Justfile") }} _package-payload "$safe_package") safe_package_sha256=$(sha256sum "$safe_package" | awk '{ print $1 }') safe_entry_count=$(wc -l < "$safe_list") - ((sys_package_size <= 6 * 1024 * 1024)) - ((sys_unpacked_size <= 40 * 1024 * 1024)) - ((sys_entry_count <= 1400)) - ((safe_package_size <= 128 * 1024)) - ((safe_unpacked_size <= 512 * 1024)) - ((safe_entry_count <= 40)) jq -e --arg version "$version" ' .packages | length == 1 @@ -870,72 +862,13 @@ package-test: cargo run --locked --release --offline ) - printf 'sys package: %d entries, %d regular-file bytes, %d du bytes, %d compressed bytes, sha256 %s\n' \ - "$sys_entry_count" "$sys_regular_file_bytes" "$sys_unpacked_size" \ + printf 'sys package: %d entries, %d payload bytes, %d compressed bytes, sha256 %s\n' \ + "$sys_entry_count" "$sys_payload_size" \ "$sys_package_size" "$sys_package_sha256" - printf 'safe package: %d entries, %d bytes unpacked, %d bytes compressed, sha256 %s\n' \ - "$safe_entry_count" "$safe_unpacked_size" "$safe_package_size" \ + printf 'safe package: %d entries, %d payload bytes, %d compressed bytes, sha256 %s\n' \ + "$safe_entry_count" "$safe_payload_size" "$safe_package_size" \ "$safe_package_sha256" -[private] -_native-capi-known-flake output: - #!/usr/bin/env bash - set -euo pipefail - python3 - {{ quote(output) }} <<'PY' - import re - import sys - - text = open(sys.argv[1], encoding="utf-8", errors="replace").read() - - cases = re.findall(r"^\s*TEST CASE:\s*(.*?)\s*$", text, re.MULTILINE) - if cases != ["capi: vllm_complete_stream early-stop tears the request down cleanly"]: - raise SystemExit(f"native C API failure was not the sole known test case: {cases}") - - errors = re.findall( - r"^.*test_capi\.cpp:(\d+): ERROR:\s*(.*?)\s*$", text, re.MULTILINE - ) - expected_error = [("680", "CHECK( acc.deltas == 2 ) is NOT correct!")] - if errors != expected_error: - raise SystemExit(f"native C API failure did not have the exact known assertion: {errors}") - - values = re.findall(r"^\s*values:\s*(.*?)\s*$", text, re.MULTILINE) - if values != ["CHECK( 1 == 2 )"]: - raise SystemExit(f"native C API failure did not observe exactly 1 == 2: {values}") - - case_summaries = re.findall( - r"^\[doctest\]\s+test cases:\s*(\d+)\s*\|\s*(\d+) passed\s*\|\s*" - r"(\d+) failed\s*\|\s*(\d+) skipped\s*$", - text, - re.MULTILINE, - ) - if case_summaries != [("49", "48", "1", "0")]: - raise SystemExit(f"native C API test-case summary was not the known sole failure: {case_summaries}") - - assertion_summaries = re.findall( - r"^\[doctest\]\s+assertions:\s*(\d+)\s*\|\s*(\d+) passed\s*\|\s*" - r"(\d+) failed\s*\|\s*$", - text, - re.MULTILINE, - ) - if len(assertion_summaries) != 1: - raise SystemExit(f"native C API assertion summary was ambiguous: {assertion_summaries}") - total, passed, failed = map(int, assertion_summaries[0]) - if failed != 1 or passed + failed != total: - raise SystemExit(f"native C API assertion summary was not one failure: {assertion_summaries}") - - ctest_summaries = re.findall( - r"^(\d+)% tests passed, (\d+) tests failed out of (\d+)$", text, re.MULTILINE - ) - if ctest_summaries != [("0", "1", "1")]: - raise SystemExit(f"CTest summary was not the exact test_capi failure: {ctest_summaries}") - - failed_tests = re.findall( - r"^\s*[0-9]+\s+-\s+(\S+)\s+\(([^)]+)\)\s*$", text, re.MULTILINE - ) - if failed_tests != [("test_capi", "Failed")]: - raise SystemExit(f"CTest failure list was not exactly test_capi: {failed_tests}") - PY - # Build and run the focused native CPU C API fixture gate. native-capi: #!/usr/bin/env bash @@ -966,8 +899,7 @@ native-capi: listing=$(mktemp) output=$(mktemp) - retry_output=$(mktemp) - trap 'rm -f "$listing" "$output" "$retry_output"' EXIT + trap 'rm -f "$listing" "$output"' EXIT ctest --test-dir "$build" -N --tests-regex '^test_capi$' | tee "$listing" test_count=$(grep -Ec '^[[:space:]]*Test #[0-9]+: test_capi$' "$listing") [[ $test_count -eq 1 ]] @@ -984,16 +916,8 @@ native-capi: exit 0 fi - just --justfile {{ quote(root + "/Justfile") }} \ - _native-capi-known-flake "$output" - # The synchronous scheduler makes pending-delta delivery cardinality - # deterministic while the complete suite still exercises early-stop abort, - # request teardown, and engine reuse through the unchanged C ABI test. - echo 'retrying complete test_capi once with VT_ASYNC_SCHED=0' >&2 - env -u VT_ASYNC_RUNNER VT_ASYNC_SCHED=0 \ - ctest --test-dir "$build" --output-on-failure --tests-regex '^test_capi$' \ - 2>&1 | tee "$retry_output" - grep -Fxq '100% tests passed, 0 tests failed out of 1' "$retry_output" + echo "default native C API gate failed (status $default_status); alternate scheduler runs cannot satisfy this gate" >&2 + exit "$default_status" # Run sys-first crates.io publication checks without uploading. publish-dry-run: @@ -1053,79 +977,10 @@ sanitizers-model-free: cargo test --locked -p vllm-cpp --lib --test safe_api --test qwen3 \ --no-run --message-format=json > "$messages" - test_binary() { - local target=$1 - python3 - "$messages" "$target" <<'PY' - import json - import sys - - messages, target = sys.argv[1:] - matches = [] - with open(messages, encoding="utf-8") as stream: - for line in stream: - message = json.loads(line) - if ( - message.get("reason") == "compiler-artifact" - and message.get("profile", {}).get("test") - and message.get("target", {}).get("name") == target - and message.get("executable") - ): - matches.append(message["executable"]) - if len(matches) != 1: - raise SystemExit( - f"expected exactly one test binary for {target}, found {len(matches)}: {matches}" - ) - print(matches[0]) - PY - } - - assert_complete_suite() { - local suite=$1 - local output=$2 - python3 - "$output" "$suite" <<'PY' - import re - import sys - - output, suite = sys.argv[1:] - text = open(output, encoding="utf-8", errors="replace").read() - running = re.findall(r"^running ([0-9]+) tests?$", text, re.MULTILINE) - summaries = re.findall( - r"^test result: ok\. ([0-9]+) passed; 0 failed;", text, re.MULTILINE - ) - if len(running) != 1 or int(running[0]) < 1: - raise SystemExit( - f"{suite} suite did not report exactly one nonzero running count: {running}" - ) - if len(summaries) != 1 or int(summaries[0]) < 1: - raise SystemExit( - f"{suite} suite did not report exactly one successful nonzero summary: {summaries}" - ) - PY - } - - assert_focused_test() { - local test=$1 - local output=$2 - python3 - "$output" "$test" <<'PY' - import re - import sys - - output, test = sys.argv[1:] - text = open(output, encoding="utf-8", errors="replace").read() - running = re.findall(r"^running ([0-9]+) tests?$", text, re.MULTILINE) - summaries = re.findall( - r"^test result: ok\. ([0-9]+) passed; ([0-9]+) failed;", text, re.MULTILINE - ) - if running != ["1"]: - raise SystemExit(f"{test} did not report exactly one running test: {running}") - if summaries != [("1", "0")]: - raise SystemExit(f"{test} did not report exactly one passed test: {summaries}") - PY - } - - library=$(test_binary vllm_cpp) - safe_api=$(test_binary safe_api) - qwen3=$(test_binary qwen3) + package=$(cargo pkgid --locked -p vllm-cpp) + library=$(just --justfile {{ quote(root + "/Justfile") }} _test-binary "$messages" "$package" vllm_cpp lib) + safe_api=$(just --justfile {{ quote(root + "/Justfile") }} _test-binary "$messages" "$package" safe_api test) + qwen3=$(just --justfile {{ quote(root + "/Justfile") }} _test-binary "$messages" "$package" qwen3 test) for binary in "$library" "$safe_api" "$qwen3"; do [[ -x $binary ]] || { echo "test binary is not executable: $binary" >&2 @@ -1139,25 +994,25 @@ sanitizers-model-free: echo 'GCC sanitizer runtimes are unavailable' >&2 exit 1 fi - export LD_PRELOAD="$asan:$ubsan${LD_PRELOAD:+:$LD_PRELOAD}" + sanitizer_preload="$asan:$ubsan${LD_PRELOAD:+:$LD_PRELOAD}" export ASAN_OPTIONS=${ASAN_OPTIONS:-detect_leaks=1:halt_on_error=1} export UBSAN_OPTIONS=${UBSAN_OPTIONS:-halt_on_error=1:print_stacktrace=1} export VT_POOL_BYPASS=1 library_output="$work/library.out" - "$library" --test-threads=1 2>&1 | tee "$library_output" - assert_complete_suite library "$library_output" + LD_PRELOAD="$sanitizer_preload" "$library" --test-threads=1 2>&1 | tee "$library_output" + just --justfile {{ quote(root + "/Justfile") }} _assert-rust-tests "$library_output" library safe_api_output="$work/safe-api.out" - "$safe_api" --test-threads=1 2>&1 | tee "$safe_api_output" - assert_complete_suite safe_api "$safe_api_output" + LD_PRELOAD="$sanitizer_preload" "$safe_api" --test-threads=1 2>&1 | tee "$safe_api_output" + just --justfile {{ quote(root + "/Justfile") }} _assert-rust-tests "$safe_api_output" safe_api for test in \ committed_transcription_fixture_supports_path_pcm_and_wrong_task \ committed_embedding_fixture_preserves_shape_order_ownership_and_wrong_task \ committed_video_mux_goldens_preserve_exact_argument_boundaries \ committed_parakeet_directory_is_rejected_as_video_dit; do output="$work/$test.out" - "$qwen3" --test-threads=1 --exact "$test" 2>&1 | tee "$output" - assert_focused_test "$test" "$output" + LD_PRELOAD="$sanitizer_preload" "$qwen3" --test-threads=1 --exact "$test" 2>&1 | tee "$output" + just --justfile {{ quote(root + "/Justfile") }} _assert-rust-tests "$output" "$test" true done echo 'sanitizer gate passed: 3 binaries, 2 complete suites, 4 focused fixture tests' @@ -1179,7 +1034,12 @@ sanitizers model=env_var_or_default("VLLM_CPP_TEST_MODEL", ""): export VLLM_CPP_SANITIZE=address,undefined export CARGO_TARGET_DIR=${CARGO_TARGET_DIR:-{{ quote(root + "/target/sanitize") }}} - cargo test --locked -p vllm-cpp --test safe_api --test qwen3 --no-run + work=$(mktemp -d) + trap 'rm -rf "$work"' EXIT + messages="$work/messages.json" + cargo test --locked -p vllm-cpp --test safe_api --test qwen3 \ + --no-run --message-format=json > "$messages" + package=$(cargo pkgid --locked -p vllm-cpp) asan=$(gcc -print-file-name=libasan.so) ubsan=$(gcc -print-file-name=libubsan.so) @@ -1187,20 +1047,17 @@ sanitizers model=env_var_or_default("VLLM_CPP_TEST_MODEL", ""): echo 'GCC sanitizer runtimes are unavailable' >&2 exit 1 fi - export LD_PRELOAD="$asan:$ubsan${LD_PRELOAD:+:$LD_PRELOAD}" + sanitizer_preload="$asan:$ubsan${LD_PRELOAD:+:$LD_PRELOAD}" export ASAN_OPTIONS=${ASAN_OPTIONS:-detect_leaks=1:halt_on_error=1} export UBSAN_OPTIONS=${UBSAN_OPTIONS:-halt_on_error=1:print_stacktrace=1} export VT_POOL_BYPASS=1 - for pattern in safe_api qwen3; do - binary=$(find "$CARGO_TARGET_DIR/debug/deps" -maxdepth 1 -type f \ - -name "$pattern-*" -executable -printf '%T@ %p\n' \ - | sort -n | tail -1 | cut -d' ' -f2-) || true - if [[ -z $binary ]]; then - echo "could not find $pattern test binary" >&2 - exit 1 - fi - "$binary" --test-threads=1 + for target in safe_api qwen3; do + binary=$(just --justfile {{ quote(root + "/Justfile") }} _test-binary "$messages" "$package" "$target" test) + [[ -x $binary ]] + output="$work/$target.out" + LD_PRELOAD="$sanitizer_preload" "$binary" --test-threads=1 2>&1 | tee "$output" + just --justfile {{ quote(root + "/Justfile") }} _assert-rust-tests "$output" "$target" done # Run selected request lifecycle tests under native-only GCC TSan on Linux x86_64. @@ -1226,26 +1083,25 @@ tsan model=env_var_or_default("VLLM_CPP_TEST_MODEL", ""): export VLLM_CPP_SANITIZE=thread export CARGO_TARGET_DIR=${CARGO_TARGET_DIR:-{{ quote(root + "/target/tsan") }}} - cargo test --locked -p vllm-cpp --test qwen3 --no-run + work=$(mktemp -d) + trap 'rm -rf "$work"' EXIT + messages="$work/messages.json" + cargo test --locked -p vllm-cpp --test qwen3 --no-run --message-format=json > "$messages" + package=$(cargo pkgid --locked -p vllm-cpp) + binary=$(just --justfile {{ quote(root + "/Justfile") }} _test-binary "$messages" "$package" qwen3 test) + [[ -x $binary ]] tsan=$(gcc -print-file-name=libtsan.so) if [[ ! -f $tsan ]]; then echo 'GCC ThreadSanitizer runtime is unavailable' >&2 exit 1 fi - export LD_PRELOAD="$tsan${LD_PRELOAD:+:$LD_PRELOAD}" + sanitizer_preload="$tsan${LD_PRELOAD:+:$LD_PRELOAD}" # Rust and its standard library are not instrumented in this GCC lane. Keep # native vllm.cpp races visible while ignoring uninstrumented Rust modules. export TSAN_OPTIONS=${TSAN_OPTIONS:-halt_on_error=1:ignore_noninstrumented_modules=1} export VT_POOL_BYPASS=1 - binary=$(find "$CARGO_TARGET_DIR/debug/deps" -maxdepth 1 -type f \ - -name 'qwen3-*' -executable -printf '%T@ %p\n' \ - | sort -n | tail -1 | cut -d' ' -f2-) || true - if [[ -z $binary ]]; then - echo 'could not find qwen3 test binary' >&2 - exit 1 - fi # Self-drop uses uninstrumented Rust synchronization, so normal and ASan/LSan # cover it. Every selected native lifecycle case runs in its own process. for test in \ @@ -1256,9 +1112,75 @@ tsan model=env_var_or_default("VLLM_CPP_TEST_MODEL", ""): callback_panic_is_reported_and_engine_is_reusable \ request_retains_engine_and_live_drop_is_safe \ concurrent_request_lifecycle_stress; do - "$binary" "$test" --exact --test-threads=1 + output="$work/$test.out" + LD_PRELOAD="$sanitizer_preload" "$binary" "$test" --exact --test-threads=1 2>&1 | tee "$output" + just --justfile {{ quote(root + "/Justfile") }} _assert-rust-tests "$output" "$test" true done +# Test release evidence helpers without native builds or models. +tooling-test: + python3 {{ quote(root + "/tests/release_tooling.py") }} + +[private] +_package-payload archive: + #!/usr/bin/env bash + set -euo pipefail + python3 - {{ quote(archive) }} <<'PYTHON' + import sys + import tarfile + + with tarfile.open(sys.argv[1], "r:gz") as archive: + print(sum(member.size for member in archive if member.isfile())) + PYTHON + +[private] +_test-binary messages package target kind: + #!/usr/bin/env bash + set -euo pipefail + python3 - {{ quote(messages) }} {{ quote(package) }} {{ quote(target) }} {{ quote(kind) }} <<'PYTHON' + import json + import sys + + messages, package, target, kind = sys.argv[1:] + matches = [] + with open(messages, encoding="utf-8") as stream: + for line in stream: + message = json.loads(line) + if ( + message.get("reason") == "compiler-artifact" + and message.get("package_id") == package + and message.get("profile", {}).get("test") + and message.get("target", {}).get("name") == target + and message.get("target", {}).get("kind") == [kind] + and message.get("executable") + ): + matches.append(message["executable"]) + if len(matches) != 1: + raise SystemExit(f"expected exactly one {package} {target} {kind} test binary, found {matches}") + print(matches[0]) + PYTHON + +[private] +_assert-rust-tests output suite focused="false": + #!/usr/bin/env bash + set -euo pipefail + python3 - {{ quote(output) }} {{ quote(suite) }} {{ quote(focused) }} <<'PYTHON' + import re + import sys + + output, suite, focused = sys.argv[1:] + text = open(output, encoding="utf-8", errors="replace").read() + running = re.findall(r"^running ([0-9]+) tests?$", text, re.MULTILINE) + passed = re.findall(r"^test result: ok\. ([0-9]+) passed; 0 failed;", text, re.MULTILINE) + if len(running) != 1 or int(running[0]) < 1 or passed != running: + raise SystemExit(f"{suite}: expected one complete nonzero passing suite; running={running}, passed={passed}") + if focused == "true": + # Native stderr can split libtest's name and trailing `ok` across lines. + named = re.findall(r"^test " + re.escape(suite) + r" \.\.\. ", text, re.MULTILINE) + if running != ["1"] or len(named) != 1: + raise SystemExit(f"{suite}: expected exactly one named passed test") + PYTHON + # Check Just and Rust formatting. fmt-check: just --unstable --justfile {{ quote(root + "/Justfile") }} --fmt --check @@ -1273,4 +1195,4 @@ docs: RUSTDOCFLAGS="-D warnings" cargo doc --locked --workspace --no-deps --features vllm-cpp/serde # Run the complete maintainer gate serially; do not pass --jobs. -ci: fmt-check lint docs test sys link-modes native-capi sanitizers-model-free package-test publish-dry-run +ci: fmt-check tooling-test lint docs test sys link-modes native-capi sanitizers-model-free package-test publish-dry-run diff --git a/README.md b/README.md index a46a14b..d8207b2 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,7 @@ just sanitizers "$model" just tsan "$model" ``` -These commands are optional evidence and are recorded only when rerun against the exact candidate. The TSan recipe instruments native C++ only and does not claim Rust standard-library race coverage. `VLLM_CPP_SANITIZE` is a bundled-build test input; system mode rejects it. +These commands are optional evidence and are recorded only when rerun against the exact candidate. Sanitizer recipes use exact package/target executables from Cargo JSON, require nonzero complete suites, and reject an exact TSan filter unless one named test passes. The TSan recipe instruments native C++ only and does not claim Rust standard-library race coverage. `VLLM_CPP_SANITIZE` is a bundled-build test input; system mode rejects it. ## Link Modes @@ -131,13 +131,15 @@ cargo package -p vllm-cpp --locked --list just package-test ``` -The package gate validates deterministic inventories for both crates, package metadata, required source/docs/examples/tests/licenses/native inputs, forbidden payloads, and license provenance. It extracts and tests both crates offline, then runs independent sys and safe downstream consumers; the safe consumer resolves both extracted crates rather than this workspace. The sys package carries only native build inputs and required licenses/notices; upstream tests, large fixtures, media, benchmarks, fetched SDKs, external CUTLASS trees, and agent records are excluded. +The package gate reports file counts, compressed sizes, payload bytes, and SHA-256 hashes as diagnostics only; neither crate has an arbitrary numeric package size or file-count cap. Payload bytes are the sum of regular archive member sizes, not filesystem-dependent extracted directory sizes. It validates deterministic inventories for both crates, package metadata, required source/docs/examples/tests/licenses/native inputs, forbidden payloads, and license provenance. It extracts and tests both crates offline, then runs independent sys and safe downstream consumers; the safe consumer resolves both extracted crates rather than this workspace. The sys package carries only native build inputs and required licenses/notices; upstream tests, large fixtures, media, benchmarks, fetched SDKs, external CUTLASS trees, and agent records are excluded. `just publish-dry-run` performs a sys-then-safe workspace packaging dry-run without uploading; it uses `--no-verify` to avoid the pre-publication registry cycle. As required by [RELEASING.md](RELEASING.md), after `vllm-cpp-sys` is available from crates.io, run the full `cargo publish -p vllm-cpp --locked --dry-run` verification before publishing the safe crate. ## Validation Boundary -Mandatory candidate evidence is Linux x86_64 CPU and model-free: formatting, lint, docs, workspace tests, generated-binding/header/layout/signature/ABI/exact-export checks, all four CPU link modes, native C API fixtures, ASan/UBSan/leak checks over committed fixtures, package extraction/downstream checks, publication dry-run, and exact MSRV validation. +Mandatory candidate evidence is Linux x86_64 CPU and model-free: formatting, release-tooling regressions, lint, docs, workspace tests, generated-binding/header/layout/signature/ABI/exact-export checks, all four CPU link modes, native C API fixtures, ASan/UBSan/leak checks over committed fixtures, package extraction/downstream checks, publication dry-run, and exact MSRV validation. + +`just native-capi` preserves the default scheduler result with `VT_ASYNC_SCHED` and `VT_ASYNC_RUNNER` unset. A failing default fixture blocks the candidate; a separate synchronous diagnostic run cannot make it green. Callback cardinality is not token cardinality. Dirty-tree development passes are not a substitute for independently approved clean-commit validation in [RELEASING.md](RELEASING.md). Prepared Qwen inference/sanitizers, native-only TSan, successful Rust MiniMax-H3 generation, Miri, Linux ARM64, Apple ARM64, Vulkan, CUDA/CUTLASS/Triton, Metal/MLX, and accelerator runtime are optional or deferred lanes. The manual platform workflows are configuration, not exact-candidate evidence unless separately dispatched and recorded. diff --git a/RELEASING.md b/RELEASING.md index 726d885..86e0059 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -2,6 +2,8 @@ Releases are prepared and published manually. A candidate pass or dry-run does not authorize a Git tag, crates.io upload, merge, push, or GitHub release. Publication is an irreversible registry action and requires separate maintainer authorization. +Uncommitted development runs may exercise the gates below, but must be reported as dirty-tree validation, not approved release proof. Formal release validation and publication require the independently approved exact commit and clean identities below. + ## Prepare a candidate 1. Obtain `VLLM_CPP_RELEASE_COMMIT` from the independent approval of the exact release commit. Never derive the expected value from the current checkout. Require a lowercase, full 40-character SHA, verify that it names a commit object, and compare it with the root checkout before any candidate gate: @@ -63,11 +65,13 @@ RUSTDOCFLAGS='-D warnings' cargo doc --locked --workspace --no-deps --features v git diff --check ``` -`just ci` includes formatting, warnings-denied lint/docs, model-free workspace tests, generated bindings and ABI conformance, four CPU link modes, the native C API fixture, model-free ASan/UBSan/leak checks, package/extracted/downstream validation, and no-upload publish dry-run. Run the exact MSRV gate separately so stable-toolchain success cannot mask it. +`just ci` includes formatting, deterministic release-tooling regressions, warnings-denied lint/docs, model-free workspace tests, generated bindings and ABI conformance, four CPU link modes, the native C API fixture, model-free ASan/UBSan/leak checks, package/extracted/downstream validation, and no-upload publish dry-run. Run the exact MSRV gate separately so stable-toolchain success cannot mask it. + +The native fixture gate always runs with `VT_ASYNC_SCHED` and `VT_ASYNC_RUNNER` unset and preserves any default-run failure. No assertion is waived or excluded. A separate `VT_ASYNC_SCHED=0` run may help diagnose callback coalescing, but cannot satisfy the default gate or establish token loss from callback cardinality alone. Record default and diagnostic commands/statuses independently; a default failure is a candidate blocker. If `just ci` stops, run the remaining recipes separately to expose other blockers, not to relabel the aggregate as passed. -Nix support remains an optional convenience, not a prerequisite. Maintainers who choose it may run `nix develop -c env -u VLLM_CPP_TEST_MODEL just ci` and `nix develop .#msrv -c just msrv`; `nix flake check --no-build` is an additional Nix-specific evaluation check, not an ordinary release gate. +Nix support remains an optional convenience, not a prerequisite. Maintainers who choose it may run `nix develop --no-write-lock-file -c env -u VLLM_CPP_TEST_MODEL just ci` and `nix develop .#msrv --no-write-lock-file -c just msrv`; `nix flake check --no-build` is an additional Nix-specific evaluation check, not an ordinary release gate. -Prepared-Qwen inference/sanitizers, native-only TSan, successful Rust MiniMax-H3 generation, Miri, Linux ARM64, Apple ARM64, Vulkan, CUDA/CUTLASS/Triton, Metal/MLX, and accelerator runtime are optional or deferred. Record one only when it ran against the exact candidate; configured workflows and older results are not candidate evidence. +Prepared-Qwen inference/sanitizers, native-only TSan, successful Rust MiniMax-H3 generation, Miri, Linux ARM64, Apple ARM64, Vulkan, CUDA/CUTLASS/Triton, Metal/MLX, and accelerator runtime are optional or deferred. Record one only when it ran against the exact candidate; configured workflows and older results are not candidate evidence. Sanitizer recipes select one package/target test artifact from Cargo JSON rather than target-directory timestamps. Full suites must report matching nonzero running/passed counts, and every exact TSan filter must report one named passed test; a missing filter is a failure, not coverage. ## Inspect packages @@ -83,7 +87,7 @@ For both runs, retain: - sorted `cargo package --list` inventories and archive basenames; - normalized `Cargo.toml` manifests, including exact `vllm-cpp-sys =0.0.2`; -- file counts and unpacked/compressed sizes, enforcing sys limits of 1,400 files, 40 MiB unpacked, and 6 MiB compressed and safe limits of 40 files, 512 KiB unpacked, and 128 KiB compressed; +- file counts and payload/compressed sizes as diagnostics only, with no arbitrary numeric package size or file-count caps for either crate; payload means the sum of regular archive member sizes for both crates, excluding directory metadata and archive headers, not `du` of the extracted tree; exact inventory, content, provenance, and compatibility checks remain mandatory; - complete license/notice/provenance inventories and checks that current READMEs, source, tests, examples, and required native/backend inputs are present; - SHA-256 for both archives from each run and successful extracted/offline builds, all four extracted sys link modes, and independent sys and safe downstream consumers. diff --git a/tests/release_tooling.py b/tests/release_tooling.py new file mode 100644 index 0000000..9b1956b --- /dev/null +++ b/tests/release_tooling.py @@ -0,0 +1,215 @@ +"""Deterministic tests of release recipes; no native build or model required.""" + +import io +import json +import os +from pathlib import Path +import subprocess +import sys +import tarfile +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] +PACKAGE = "path+file:///fixture/vllm-cpp#0.0.2" + + +class ReleaseTooling(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix="vllm-release-tooling-") + self.work = Path(self.temp.name) + self.addCleanup(self.temp.cleanup) + + def recipe(self, *args, env=None): + return subprocess.run( + ["just", "--justfile", str(ROOT / "Justfile"), *map(str, args)], + cwd=ROOT, env=env, text=True, capture_output=True, timeout=15, + ) + + def artifact(self, executable, package=PACKAGE, target="qwen3", kind="test"): + return { + "reason": "compiler-artifact", "package_id": package, + "target": {"name": target, "kind": [kind]}, + "profile": {"test": True}, "executable": str(executable), + } + + def select(self, messages): + path = self.work / "messages.json" + path.write_text("".join(json.dumps(message) + "\n" for message in messages)) + return self.recipe("_test-binary", path, PACKAGE, "qwen3", "test") + + def test_artifact_selection_ignores_newer_stale_and_foreign_executables(self): + current = self.work / "qwen3-current" + stale = self.work / "qwen3-stale" + for path in [current, stale]: + path.touch(mode=0o755) + os.utime(current, (1, 1)) + os.utime(stale, (999999, 999999)) + result = self.select([ + self.artifact(stale, package="registry+other#foreign@0.0.2"), + self.artifact(stale, kind="example"), + self.artifact(stale, target="safe_api"), + self.artifact(current), + ]) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.strip(), str(current)) + + def test_artifact_selection_rejects_missing_or_ambiguous_matches(self): + for messages in [[], [self.artifact("one"), self.artifact("two")]]: + with self.subTest(messages=messages): + self.assertNotEqual(self.select(messages).returncode, 0) + + def assert_results(self, text, focused=False, name="suite"): + path = self.work / "output.txt" + path.write_text(text) + return self.recipe("_assert-rust-tests", path, name, str(focused).lower()) + + def test_complete_suite_requires_nonzero_matching_counts(self): + self.assertEqual(self.assert_results( + "running 3 tests\ntest result: ok. 3 passed; 0 failed; 0 ignored;\n" + ).returncode, 0) + for text in [ + "running 0 tests\ntest result: ok. 0 passed; 0 failed;\n", + "running 3 tests\ntest result: ok. 1 passed; 0 failed; 2 ignored;\n", + "running 1 test\ntest result: FAILED. 0 passed; 1 failed;\n", + "running 1 test\nrunning 1 test\ntest result: ok. 1 passed; 0 failed;\n", + ]: + with self.subTest(text=text): + self.assertNotEqual(self.assert_results(text).returncode, 0) + + def test_focused_test_requires_exactly_one_named_pass(self): + text = "running 1 test\ntest lifecycle ... ok\ntest result: ok. 1 passed; 0 failed;\n" + self.assertEqual(self.assert_results(text, True, "lifecycle").returncode, 0) + native_output = text.replace("... ok", "... native diagnostic\nok") + self.assertEqual(self.assert_results(native_output, True, "lifecycle").returncode, 0) + for output in [ + "running 0 tests\ntest result: ok. 0 passed; 0 failed;\n", + text.replace("lifecycle", "different"), + text.replace("1 passed", "0 passed"), + ]: + with self.subTest(output=output): + self.assertNotEqual(self.assert_results(output, True, "lifecycle").returncode, 0) + + def test_optional_lanes_use_cargo_artifacts_and_result_checks(self): + text = (ROOT / "Justfile").read_text() + for start, end in [("sanitizers model=", "# Run selected request"), + ("tsan model=", "# Check Just")]: + recipe = text.split(start, 1)[1].split(end, 1)[0] + self.assertIn("--message-format=json", recipe) + self.assertIn("_test-binary", recipe) + self.assertIn("_assert-rust-tests", recipe) + self.assertNotIn("find ", recipe) + + def test_package_payload_ignores_directory_metadata_and_links(self): + for count in [0, 100]: + archive = self.work / f"payload-{count}.crate" + with tarfile.open(archive, "w:gz") as stream: + for number in range(count): + member = tarfile.TarInfo(f"pkg/empty-{number}/") + member.type = tarfile.DIRTYPE + stream.addfile(member) + link = tarfile.TarInfo("pkg/link") + link.type = tarfile.SYMTYPE + link.linkname = "payload" + stream.addfile(link) + member = tarfile.TarInfo("pkg/payload") + member.size = 53 + stream.addfile(member, io.BytesIO(b"x" * 53)) + result = self.recipe("_package-payload", archive) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.strip(), "53") + + def test_package_metrics_are_diagnostics_not_release_caps(self): + package = (ROOT / "Justfile").read_text().split( + "\npackage-test:\n", 1 + )[1].split("\n# Build and run the focused native", 1)[0] + for crate in ["sys", "safe"]: + for suffix in ["package_size", "payload_size", "entry_count", "package_sha256"]: + metric = f"{crate}_{suffix}" + uses = [line.strip() for line in package.splitlines() if metric in line] + with self.subTest(metric=metric): + # Metrics may be computed and printed, never used as gate conditions. + self.assertEqual(len(uses), 2, uses) + self.assertTrue(uses[0].startswith(f"{metric}=$("), uses) + self.assertIn(f'"${metric}"', uses[1]) + self.assertIn(f"{crate} package: %d entries, %d payload bytes, " + "%d compressed bytes, sha256 %s", package) + self.assertIn(f'_package-payload "${crate}_package"', package) + + def test_package_content_and_compatibility_checks_remain_required(self): + package = (ROOT / "Justfile").read_text().split( + "\npackage-test:\n", 1 + )[1].split("\n# Build and run the focused native", 1)[0] + for check in [ + 'diff -u "$sys_list" <(archive_inventory', + 'diff -u "$safe_list" <(archive_inventory', + 'diff -u "$sys_expected" "$sys_list"', + 'diff -u "$safe_expected" "$safe_list"', + 'required_sys_members=(', 'done < "$safe_expected"', + 'denied_sys_members=(', '"$forbidden_pattern" "$listing"', + 'scan_authored_paths "$sys_root"', 'scan_authored_paths "$safe_root"', + '"$sys_license_actual"', 'licenses/DOCTEST-MIT.txt', + 'check_package_links "$sys_root" README.md THIRD_PARTY.md', + 'check_package_links "$safe_root" README.md examples/README.md', + 'cargo metadata --manifest-path "$sys_root/Cargo.toml"', + 'cargo metadata --manifest-path "$safe_root/Cargo.toml"', + '.req == ("=" + $version)', + '"$sys_root" "$temp/extracted-link-modes"', + 'cargo test --locked --release --offline --features bundled,serde', + 'cd "$sys_consumer"', 'cd "$safe_consumer"', + '.manifest_path == $safe', '.manifest_path == $sys', + ]: + with self.subTest(check=check): + self.assertIn(check, package) + self.assertEqual(package.count('cargo run --locked --release --offline'), 2) + self.assertIn("package-test publish-dry-run", + (ROOT / "Justfile").read_text().split("\nci:", 1)[1]) + + def run_native(self, status): + tools = self.work / "bin" + tools.mkdir() + cmake = tools / "cmake" + cmake.write_text("#!/usr/bin/env bash\nexit 0\n") + cmake.chmod(0o755) + ctest = tools / "ctest" + ctest.write_text(f"#!{sys.executable}\n" + '''import json, os, sys +with open(os.environ["MOCK_LOG"], "a") as log: + log.write(json.dumps({"args": sys.argv[1:], "sched": os.getenv("VT_ASYNC_SCHED"), "runner": os.getenv("VT_ASYNC_RUNNER")}) + "\\n") +if "-N" in sys.argv: + print(" Test #1: test_capi\\nTotal Tests: 1") + sys.exit(0) +if os.getenv("VT_ASYNC_SCHED") == "0" or os.environ["MOCK_STATUS"] == "0": + print("100% tests passed, 0 tests failed out of 1") + sys.exit(0) +print("""TEST CASE: capi: vllm_complete_stream early-stop tears the request down cleanly +test_capi.cpp:680: ERROR: CHECK( acc.deltas == 2 ) is NOT correct! + values: CHECK( 1 == 2 ) +[doctest] test cases: 49 | 48 passed | 1 failed | 0 skipped +[doctest] assertions: 100 | 99 passed | 1 failed | +0% tests passed, 1 tests failed out of 1 + 1 - test_capi (Failed)""") +sys.exit(int(os.environ["MOCK_STATUS"])) +''') + ctest.chmod(0o755) + log = self.work / "ctest.jsonl" + env = dict(os.environ, PATH=f"{tools}:{os.environ['PATH']}", + CARGO_TARGET_DIR=str(self.work / "target"), MOCK_LOG=str(log), + MOCK_STATUS=str(status), VT_ASYNC_SCHED="inherited", VT_ASYNC_RUNNER="inherited") + result = self.recipe("native-capi", env=env) + calls = [json.loads(line) for line in log.read_text().splitlines()] + self.assertEqual(len(calls), 2, result.stdout + result.stderr) + self.assertIsNone(calls[1]["sched"]) + self.assertIsNone(calls[1]["runner"]) + return result + + def test_native_success_uses_default_environment(self): + result = self.run_native(0) + self.assertEqual(result.returncode, 0, result.stderr) + + def test_native_failure_stays_failed_without_alternate_configuration(self): + result = self.run_native(8) + self.assertEqual(result.returncode, 8, result.stdout + result.stderr) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/vllm-cpp-sys/src/build_config.rs b/vllm-cpp-sys/src/build_config.rs index f07b3a7..04c91b5 100644 --- a/vllm-cpp-sys/src/build_config.rs +++ b/vllm-cpp-sys/src/build_config.rs @@ -250,7 +250,7 @@ fn validate_feature_implications(features: &Features) -> Result<(), ConfigError> } if features.cuda && features.vulkan { return Err(ConfigError::new( - "`cuda` and `vulkan` cannot be combined in release 0.1; build separate backend artifacts", + "`cuda` and `vulkan` cannot be combined; build separate backend artifacts", )); } Ok(()) diff --git a/vllm-cpp/src/hf.rs b/vllm-cpp/src/hf.rs index f0df18a..6bac71f 100644 --- a/vllm-cpp/src/hf.rs +++ b/vllm-cpp/src/hf.rs @@ -3,7 +3,7 @@ use std::fmt; use std::fs; use std::path::{Component, Path, PathBuf}; -use hf_hub::api::sync::ApiBuilder; +use hf_hub::api::sync::{Api, ApiBuilder}; use hf_hub::api::RepoInfo; use hf_hub::{Cache, Repo, RepoType}; use serde_json::Value; @@ -191,19 +191,69 @@ impl HuggingFaceModel { filename: &str, ) -> Result { let api = self - .api_builder(cache) + .api_builder(cache.clone()) .build() .map_err(|error| hub(format!("could not create API: {error}")))?; - let path = api - .repo(self.requested_repo()) + self.resolve_gguf_with_api(&cache, &api, filename) + } + + fn resolve_gguf_with_api( + &self, + cache: &Cache, + api: &Api, + filename: &str, + ) -> Result { + let info = api.repo(self.requested_repo()).info().map_err(|error| { + hub(format!( + "could not read metadata for `{}` at `{}`: {error}", + self.repo_id, self.revision + )) + })?; + let sha = info.sha.trim(); + // A path-safe branch name is still mutable, so require a full commit ID. + if !is_full_commit_sha(sha) { + return Err(incomplete("repository metadata has an invalid commit SHA")); + } + if is_full_commit_sha(&self.revision) && !sha.eq_ignore_ascii_case(&self.revision) { + return Err(incomplete( + "repository metadata differs from the requested commit", + )); + } + let pinned_repo = + Repo::with_revision(self.repo_id.clone(), RepoType::Model, sha.to_owned()); + let result = api + .repo(pinned_repo.clone()) .get(filename) .map_err(|error| { hub(format!( - "could not resolve `{filename}` from `{}` at `{}`: {error}", - self.repo_id, self.revision + "could not resolve `{filename}` from `{}` at `{sha}`: {error}", + self.repo_id + )) + }) + .and_then(|path| { + verify_gguf_path(&path, filename)?; + verify_snapshot_paths(&HashMap::from([(filename.to_owned(), path.clone())]), sha)?; + Ok(path) + }); + if result.is_err() { + // hf-hub writes refs/ before returning, even for a wrong snapshot. + // Restore only that immutable identity, never a stale mutable alias. + cache.repo(pinned_repo).create_ref(sha).map_err(|error| { + io_error(format!( + "could not restore pinned cache ref `{sha}`: {error}" + )) + })?; + } + let path = result?; + cache + .repo(self.requested_repo()) + .create_ref(sha) + .map_err(|error| { + io_error(format!( + "could not update cache ref `{}` for `{}`: {error}", + self.revision, self.repo_id )) })?; - verify_gguf_path(&path, filename)?; Ok(path) } @@ -290,6 +340,13 @@ impl HuggingFaceModel { )) })?; let snapshot = snapshot_for_cached_revision(cache, &self.requested_repo())?; + if is_full_commit_sha(&self.revision) + && !snapshot_sha(&snapshot)?.eq_ignore_ascii_case(&self.revision) + { + return Err(incomplete( + "cached snapshot differs from the requested commit", + )); + } verify_gguf_path(&path, filename)?; if path.parent() != Some(snapshot.as_path()) { return Err(incomplete(format!( @@ -510,6 +567,10 @@ fn verify_gguf_path(path: &Path, filename: &str) -> Result<(), HuggingFaceError> Ok(()) } +fn is_full_commit_sha(value: &str) -> bool { + value.len() == 40 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + fn snapshot_sha(snapshot: &Path) -> Result<&str, HuggingFaceError> { snapshot .file_name() @@ -733,6 +794,365 @@ mod tests { cache_fixture_at(DEFAULT_REVISION, files) } + const COMMIT: &str = "0123456789abcdef0123456789abcdef01234567"; + const MOVED: &str = "abcdef0123456789abcdef0123456789abcdef01"; + + struct MockHub { + endpoint: String, + requests: std::sync::Arc>>, + stop: std::sync::Arc, + worker: Option>, + } + + impl MockHub { + fn new(info_sha: &str, download_sha: &str) -> Self { + Self::with_download_hook(info_sha, download_sha, || {}) + } + + fn with_download_hook( + info_sha: &str, + download_sha: &str, + hook: impl FnOnce() + Send + 'static, + ) -> Self { + use std::io::{BufRead, Write}; + use std::sync::{atomic::AtomicBool, Arc, Mutex}; + use std::time::{Duration, Instant}; + + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let endpoint = format!("http://{}", listener.local_addr().unwrap()); + let requests = Arc::new(Mutex::new(Vec::new())); + let stop = Arc::new(AtomicBool::new(false)); + let seen = Arc::clone(&requests); + let done = Arc::clone(&stop); + let info_sha = info_sha.to_owned(); + let download_sha = download_sha.to_owned(); + let mut hook = Some(hook); + let worker = std::thread::spawn(move || { + let deadline = Instant::now() + Duration::from_secs(10); + while !done.load(Ordering::Acquire) && Instant::now() < deadline { + let (mut stream, _) = match listener.accept() { + Ok(connection) => connection, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(5)); + continue; + } + Err(error) => panic!("mock accept: {error}"), + }; + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + stream + .set_write_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let mut reader = std::io::BufReader::new(&stream); + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + let path = line.split_whitespace().nth(1).unwrap().to_owned(); + let mut range = String::new(); + loop { + line.clear(); + reader.read_line(&mut line).unwrap(); + if line == "\r\n" || line.is_empty() { + break; + } + if let Some((key, value)) = line.split_once(':') { + if key.eq_ignore_ascii_case("range") { + range = value.trim().to_owned(); + } + } + } + seen.lock().unwrap().push((path.clone(), range.clone())); + let (body, headers) = if path.starts_with("/api/") { + ( + serde_json::json!({"sha": info_sha, "siblings": [{"rfilename": "model.gguf"}]}).to_string(), + "Content-Type: application/json\r\n".to_owned(), + ) + } else if range == "bytes=0-0" { + if let Some(hook) = hook.take() { + hook(); + } + ( + "r".to_owned(), + format!("x-repo-commit: {download_sha}\r\netag: \"blob-a\"\r\nContent-Range: bytes 0-0/10\r\n"), + ) + } else { + // A mutable branch has moved since metadata was fetched. + let body = if path.contains(COMMIT) { + "revision-a" + } else { + "revision-b" + }; + (body.to_owned(), String::new()) + }; + write!(stream, "HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: {}\r\n{headers}\r\n{body}", body.len()).unwrap(); + } + }); + Self { + endpoint, + requests, + stop, + worker: Some(worker), + } + } + + fn api(&self, cache: Cache) -> Api { + ApiBuilder::from_cache(cache) + .with_endpoint(self.endpoint.clone()) + .with_token(None) + .with_progress(false) + .with_retries(0) + .build() + .unwrap() + } + } + + impl Drop for MockHub { + fn drop(&mut self) { + self.stop.store(true, Ordering::Release); + self.worker.take().unwrap().join().unwrap(); + } + } + + #[test] + fn gguf_online_pins_metadata_and_body_and_preserves_offline_ref() { + let temp = TempDir::new(); + let cache = Cache::new(temp.0.clone()); + let server = MockHub::new(COMMIT, COMMIT); + let model = HuggingFaceModel::gguf(REPO, "model.gguf") + .revision(REVISION) + .cache_dir(&temp.0); + let path = model + .resolve_gguf_with_api(&cache, &server.api(cache.clone()), "model.gguf") + .unwrap(); + assert_eq!( + fs::read(&path).unwrap(), + b"revision-a", + "mutable body must not poison the old commit cache" + ); + assert_eq!(snapshot_sha(path.parent().unwrap()).unwrap(), COMMIT); + let expected_url = format!("/{REPO}/resolve/{COMMIT}/model.gguf"); + assert_eq!( + *server.requests.lock().unwrap(), + vec![ + ( + format!("/api/models/{REPO}/revision/{REVISION}"), + String::new() + ), + (expected_url.clone(), "bytes=0-0".to_owned()), + (expected_url, "bytes=0-".to_owned()), + ] + ); + assert_eq!(model.offline(true).resolve().unwrap(), path); + } + + #[test] + fn gguf_online_reuses_pinned_cache_and_updates_requested_ref() { + let temp = TempDir::new(); + let cache = Cache::new(temp.0.clone()); + let pinned = cache.repo(Repo::with_revision( + REPO.to_owned(), + RepoType::Model, + COMMIT.to_owned(), + )); + pinned.create_ref(COMMIT).unwrap(); + fs::create_dir_all(pinned.pointer_path(COMMIT)).unwrap(); + fs::write(pinned.pointer_path(COMMIT).join("model.gguf"), b"cached-a").unwrap(); + let server = MockHub::new(COMMIT, COMMIT); + let model = HuggingFaceModel::gguf(REPO, "model.gguf") + .revision(REVISION) + .cache_dir(&temp.0); + let path = model + .resolve_gguf_with_api(&cache, &server.api(cache.clone()), "model.gguf") + .unwrap(); + assert_eq!(fs::read(&path).unwrap(), b"cached-a"); + assert_eq!(server.requests.lock().unwrap().len(), 1); + assert_eq!(model.offline(true).resolve().unwrap(), path); + } + + #[test] + fn gguf_online_rejects_invalid_commit_and_mismatched_download_snapshot() { + for sha in ["", "../unsafe", "main", COMMIT] { + let temp = TempDir::new(); + let cache = Cache::new(temp.0.clone()); + let server = MockHub::new(sha, MOVED); + let model = HuggingFaceModel::gguf(REPO, "model.gguf") + .revision(REVISION) + .cache_dir(&temp.0); + let result = + model.resolve_gguf_with_api(&cache, &server.api(cache.clone()), "model.gguf"); + assert!( + matches!(result, Err(HuggingFaceError::Incomplete { .. })), + "{sha}: {result:?}" + ); + assert!(matches!( + model.offline(true).resolve(), + Err(HuggingFaceError::CacheMiss { .. }) + )); + assert_eq!( + server.requests.lock().unwrap().len(), + if sha == COMMIT { 3 } else { 1 } + ); + } + } + + #[test] + fn gguf_explicit_sha_rejected_download_cannot_poison_offline() { + let temp = TempDir::new(); + let cache = Cache::new(temp.0.clone()); + let server = MockHub::new(COMMIT, MOVED); + let model = HuggingFaceModel::gguf(REPO, "model.gguf") + .revision(COMMIT) + .cache_dir(&temp.0); + assert_incomplete_contains( + model.resolve_gguf_with_api(&cache, &server.api(cache.clone()), "model.gguf"), + "snapshots/", + ); + assert!(model.offline(true).resolve().is_err()); + let ref_path = temp.0.join("models--owner--model/refs").join(COMMIT); + assert_ne!(fs::read_to_string(ref_path).unwrap(), MOVED); + assert_eq!(server.requests.lock().unwrap().len(), 3); + } + + #[test] + fn gguf_rejected_download_preserves_valid_ref_and_other_cached_file() { + for revision in [COMMIT, REVISION] { + let temp = TempDir::new(); + let cache = Cache::new(temp.0.clone()); + let model = HuggingFaceModel::gguf(REPO, "model.gguf") + .revision(revision) + .cache_dir(&temp.0); + let requested = cache.repo(model.requested_repo()); + requested.create_ref(COMMIT).unwrap(); + let snapshot = requested.pointer_path(COMMIT); + fs::create_dir_all(&snapshot).unwrap(); + fs::write(snapshot.join("other.gguf"), b"keep-me").unwrap(); + let server = MockHub::new(COMMIT, MOVED); + assert_incomplete_contains( + model.resolve_gguf_with_api(&cache, &server.api(cache.clone()), "model.gguf"), + "snapshots/", + ); + let ref_path = temp.0.join("models--owner--model/refs").join(revision); + assert_eq!(fs::read_to_string(ref_path).unwrap(), COMMIT); + assert_eq!(fs::read(snapshot.join("other.gguf")).unwrap(), b"keep-me"); + let other = HuggingFaceModel::gguf(REPO, "other.gguf") + .revision(revision) + .cache_dir(&temp.0) + .offline(true); + assert_eq!(other.resolve().unwrap(), snapshot.join("other.gguf")); + assert!(model.offline(true).resolve().is_err()); + assert_eq!(server.requests.lock().unwrap().len(), 3); + } + } + + #[test] + fn gguf_explicit_sha_rejects_different_metadata_without_touching_refs() { + let temp = TempDir::new(); + let cache = Cache::new(temp.0.clone()); + let model = HuggingFaceModel::gguf(REPO, "model.gguf") + .revision(COMMIT) + .cache_dir(&temp.0); + let requested = cache.repo(model.requested_repo()); + requested.create_ref(COMMIT).unwrap(); + let snapshot = requested.pointer_path(COMMIT); + fs::create_dir_all(&snapshot).unwrap(); + fs::write(snapshot.join("model.gguf"), b"keep-me").unwrap(); + let server = MockHub::new(MOVED, MOVED); + assert_incomplete_contains( + model.resolve_gguf_with_api(&cache, &server.api(cache.clone()), "model.gguf"), + "requested commit", + ); + assert_eq!(server.requests.lock().unwrap().len(), 1); + assert_eq!( + model.offline(true).resolve().unwrap(), + snapshot.join("model.gguf") + ); + assert_eq!(fs::read(snapshot.join("model.gguf")).unwrap(), b"keep-me"); + assert!(!temp + .0 + .join("models--owner--model/refs") + .join(MOVED) + .exists()); + } + + #[test] + fn gguf_explicit_sha_offline_rejects_wrong_ref_and_still_requires_ref() { + let temp = TempDir::new(); + let cache = Cache::new(temp.0.clone()); + let model = HuggingFaceModel::gguf(REPO, "model.gguf") + .revision(COMMIT) + .cache_dir(&temp.0) + .offline(true); + let requested = cache.repo(model.requested_repo()); + requested.create_ref(MOVED).unwrap(); + let snapshot = requested.pointer_path(MOVED); + fs::create_dir_all(&snapshot).unwrap(); + fs::write(snapshot.join("model.gguf"), b"wrong-commit").unwrap(); + assert_incomplete_contains(model.resolve(), "requested commit"); + let correct = requested.pointer_path(COMMIT); + fs::create_dir_all(&correct).unwrap(); + fs::write(correct.join("model.gguf"), b"right-commit").unwrap(); + fs::remove_file(temp.0.join("models--owner--model/refs").join(COMMIT)).unwrap(); + assert!(matches!( + model.resolve(), + Err(HuggingFaceError::CacheMiss { .. }) + )); + } + + #[test] + fn gguf_explicit_sha_download_cache_reuse_and_offline_agree() { + let temp = TempDir::new(); + let cache = Cache::new(temp.0.clone()); + let server = MockHub::new(COMMIT, COMMIT); + let model = HuggingFaceModel::gguf(REPO, "model.gguf") + .revision(COMMIT) + .cache_dir(&temp.0); + let api = server.api(cache.clone()); + let path = model + .resolve_gguf_with_api(&cache, &api, "model.gguf") + .unwrap(); + assert_eq!(snapshot_sha(path.parent().unwrap()).unwrap(), COMMIT); + assert_eq!(fs::read(&path).unwrap(), b"revision-a"); + assert_eq!( + model + .resolve_gguf_with_api(&cache, &api, "model.gguf") + .unwrap(), + path + ); + assert_eq!(server.requests.lock().unwrap().len(), 4); + assert_eq!(model.offline(true).resolve().unwrap(), path); + } + + #[test] + fn gguf_rejected_download_does_not_roll_back_mutable_alias() { + let temp = TempDir::new(); + let cache = Cache::new(temp.0.clone()); + let model = HuggingFaceModel::gguf(REPO, "model.gguf") + .revision(REVISION) + .cache_dir(&temp.0); + let requested = cache.repo(model.requested_repo()); + requested.create_ref(COMMIT).unwrap(); + let snapshot = requested.pointer_path(MOVED); + fs::create_dir_all(&snapshot).unwrap(); + fs::write(snapshot.join("other.gguf"), b"newer-alias").unwrap(); + // Another resolver updates the alias after info(), before download returns. + let server = MockHub::with_download_hook(COMMIT, MOVED, move || { + requested.create_ref(MOVED).unwrap(); + }); + assert_incomplete_contains( + model.resolve_gguf_with_api(&cache, &server.api(cache.clone()), "model.gguf"), + "snapshots/", + ); + assert_eq!( + snapshot_for_cached_revision(&cache, &model.requested_repo()).unwrap(), + snapshot + ); + assert_eq!( + fs::read(snapshot.join("other.gguf")).unwrap(), + b"newer-alias" + ); + } + #[test] fn validates_inputs_and_rejects_split_gguf() { let cases = [ diff --git a/vllm-cpp/src/params.rs b/vllm-cpp/src/params.rs index b3eb4ac..ddb5a54 100644 --- a/vllm-cpp/src/params.rs +++ b/vllm-cpp/src/params.rs @@ -555,39 +555,46 @@ unsafe extern "C" fn logits_processor_trampoline( vocab_size: i32, user_data: *mut c_void, ) { - if user_data.is_null() { - return; - } - let Some(state) = registered_logits_processor(user_data.addr()) else { - return; - }; - if state.failure.load(Ordering::Acquire) != PROCESSOR_OK { - return; - } - if n_token_ids < 0 - || vocab_size <= 0 - || (n_token_ids > 0 && token_ids.is_null()) - || logits.is_null() - || (n_token_ids > 0 && !valid_slice_layout(token_ids, n_token_ids as usize)) - || !valid_slice_layout(logits, vocab_size as usize) - { - state.record_failure(PROCESSOR_INVALID_INPUT); - return; - } + // The local Arc can be the final owner after concurrent request teardown. + // Its user captures, guards, and every early return must unwind inside Rust. + if let Err(payload) = catch_unwind(AssertUnwindSafe(|| { + if user_data.is_null() { + return; + } + let Some(state) = registered_logits_processor(user_data.addr()) else { + return; + }; + if state.failure.load(Ordering::Acquire) != PROCESSOR_OK { + return; + } + if n_token_ids < 0 + || vocab_size <= 0 + || (n_token_ids > 0 && token_ids.is_null()) + || logits.is_null() + || (n_token_ids > 0 && !valid_slice_layout(token_ids, n_token_ids as usize)) + || !valid_slice_layout(logits, vocab_size as usize) + { + state.record_failure(PROCESSOR_INVALID_INPUT); + return; + } - let _active = ActiveProcessorGuard::enter(&state); - let tokens = if n_token_ids == 0 { - &[] - } else { - // SAFETY: the native callback contract lends this aligned token slice for - // this invocation, and the validated length fits Rust slice bounds. - unsafe { slice::from_raw_parts(token_ids, n_token_ids as usize) } - }; - // SAFETY: the native callback contract lends this aligned, uniquely mutable - // logits row for this invocation, and the validated length fits slice bounds. - let logits = unsafe { slice::from_raw_parts_mut(logits, vocab_size as usize) }; - if let Err(payload) = catch_unwind(AssertUnwindSafe(|| (state.callback)(tokens, logits))) { - state.record_failure(PROCESSOR_PANICKED); + let _active = ActiveProcessorGuard::enter(&state); + let tokens = if n_token_ids == 0 { + &[] + } else { + // SAFETY: the native callback contract lends this aligned token slice for + // this invocation, and the validated length fits Rust slice bounds. + unsafe { slice::from_raw_parts(token_ids, n_token_ids as usize) } + }; + // SAFETY: the native callback contract lends this aligned, uniquely mutable + // logits row for this invocation, and the validated length fits slice bounds. + let logits = unsafe { slice::from_raw_parts_mut(logits, vocab_size as usize) }; + // Catch the body separately so capture destruction cannot double-unwind. + if let Err(payload) = catch_unwind(AssertUnwindSafe(|| (state.callback)(tokens, logits))) { + state.record_failure(PROCESSOR_PANICKED); + discard_panic_payload(payload); + } + })) { discard_panic_payload(payload); } } @@ -767,6 +774,96 @@ mod tests { } } + #[test] + fn contains_final_processor_capture_drop_panic() { + const CHILD: &str = "VLLM_CPP_PROCESSOR_DROP_CHILD"; + if let Ok(mode) = std::env::var(CHILD) { + struct PanicPayload; + impl Drop for PanicPayload { + fn drop(&mut self) { + // A ZST secondary payload exercises safe forgetting without a heap leak. + std::panic::panic_any(()); + } + } + struct Capture(Arc); + impl Drop for Capture { + fn drop(&mut self) { + self.0.fetch_add(1, Ordering::SeqCst); + std::panic::panic_any(PanicPayload); + } + } + + let dropped = Arc::new(AtomicUsize::new(0)); + let capture = Capture(Arc::clone(&dropped)); + let (entered_tx, entered_rx) = std::sync::mpsc::sync_channel(1); + let (release_tx, release_rx) = std::sync::mpsc::sync_channel(1); + let release_rx = std::sync::Mutex::new(release_rx); + let params = SamplingParams::default().logits_processor(move |_, _| { + let _keep_capture_alive = &capture; + entered_tx.send(()).unwrap(); + release_rx + .lock() + .unwrap() + .recv_timeout(std::time::Duration::from_secs(5)) + .unwrap(); + if mode == "body-and-drop" { + std::panic::panic_any(PanicPayload); + } + }); + let marshaled = params.marshal(&compatibility()).unwrap(); + let id = marshaled.raw().logits_processor_user_data.addr(); + let worker = std::thread::spawn(move || { + let mut logits = [1.0]; + unsafe { + logits_processor_trampoline( + std::ptr::null(), + 0, + logits.as_mut_ptr(), + 1, + std::ptr::without_provenance_mut(id), + ); + } + }); + entered_rx + .recv_timeout(std::time::Duration::from_secs(5)) + .unwrap(); + drop(params); + drop(marshaled); + assert!(super::registered_logits_processor(id).is_none()); + assert_eq!(dropped.load(Ordering::SeqCst), 0); + release_tx.send(()).unwrap(); + worker.join().unwrap(); + assert_eq!(dropped.load(Ordering::SeqCst), 1); + return; + } + + for mode in ["drop", "body-and-drop"] { + let mut child = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "params::tests::contains_final_processor_capture_drop_panic", + "--nocapture", + ]) + .env(CHILD, mode) + .stdout(std::process::Stdio::null()) + .spawn() + .unwrap(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15); + loop { + if let Some(status) = child.try_wait().unwrap() { + assert!(status.success(), "{mode} child failed: {status}"); + break; + } + if std::time::Instant::now() >= deadline { + child.kill().unwrap(); + child.wait().unwrap(); + panic!("{mode} child timed out"); + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + } + } + #[test] fn rejects_zero_or_unbounded_processor_and_invalid_native_shape() { for params in [