diff --git a/CHANGELOG.md b/CHANGELOG.md index 307feb7..f8f7300 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,10 +15,10 @@ This entry describes the release candidate; it does not authorize or attest publ - 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. - 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; archive inventories must match Cargo's package lists, and 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. +- Pinned online GGUF and Safetensors 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. -- Simplified duplicated packaging policy and release documentation, and replaced the video downloader/mux application with a minimal local-checkpoint PPM/WAV example. Public library APIs and centralized native-buffer safety checks are unchanged. +- Simplified duplicated packaging policy and release documentation, and replaced the video downloader/mux application with a minimal local-checkpoint PPM/WAV example. Narrowed the committed WAV fixture reader, repeated link-mode tests, native-gate status plumbing, and redundant build/sampling checks. Bundled builds now watch the complete native include directory. Public library APIs and centralized native-buffer safety checks are unchanged. ### Added diff --git a/Justfile b/Justfile index 50f5b4f..7d701b5 100644 --- a/Justfile +++ b/Justfile @@ -208,7 +208,7 @@ _link-modes crate_root target_base: bundled_static_target="$work/bundled-static" echo '==> bundled static' CARGO_TARGET_DIR="$bundled_static_target" \ - cargo test --locked -p vllm-cpp-sys --release --tests + cargo test --locked -p vllm-cpp-sys --release --test layout --test symbols bundled_dynamic_target="$work/bundled-dynamic" echo '==> bundled dynamic build' @@ -221,7 +221,7 @@ _link-modes crate_root target_base: echo '==> bundled dynamic test' LD_LIBRARY_PATH="$bundled_dynamic_lib_dir${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" \ CARGO_TARGET_DIR="$bundled_dynamic_target" \ - cargo test --locked -p vllm-cpp-sys --release --tests --features dynamic-link + cargo test --locked -p vllm-cpp-sys --release --test layout --test symbols --features dynamic-link prefix="$work/system-prefix" mkdir -p "$prefix/include" "$prefix/lib" "$prefix/lib64" "$prefix/blake3-lib" @@ -242,7 +242,7 @@ _link-modes crate_root target_base: VLLM_CPP_ROOT="$prefix" \ VLLM_CPP_BLAKE3_LIB_DIR="$prefix/blake3-lib" \ CARGO_TARGET_DIR="$system_static_target" \ - cargo test --locked -vv -p vllm-cpp-sys --release --tests \ + cargo test --locked -vv -p vllm-cpp-sys --release --test layout --test symbols \ --no-default-features --features system 2>&1 | tee "$system_static_log" grep -Fq "cargo:rerun-if-changed=$prefix/lib/libvllm.a" "$system_static_log" grep -Fq \ @@ -269,7 +269,7 @@ _link-modes crate_root target_base: VLLM_CPP_ROOT="$prefix" \ VLLM_CPP_BLAKE3_LIB_DIR="$prefix/blake3-lib" \ CARGO_TARGET_DIR="$system_static_target" \ - cargo test --locked -vv -p vllm-cpp-sys --release --tests \ + cargo build --locked -vv -p vllm-cpp-sys --release \ --no-default-features --features system 2>&1 | tee "$system_static_rerun_log" grep -Fq 'Dirty vllm-cpp-sys' "$system_static_rerun_log" grep -Fq 'libvllm.a' "$system_static_rerun_log" @@ -317,7 +317,7 @@ _link-modes crate_root target_base: LD_LIBRARY_PATH="$prefix/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" \ VLLM_CPP_ROOT="$prefix" \ CARGO_TARGET_DIR="$system_dynamic_target" \ - cargo test --locked -vv -p vllm-cpp-sys --release --tests \ + cargo test --locked -vv -p vllm-cpp-sys --release --test layout --test symbols \ --no-default-features --features system,dynamic-link 2>&1 \ | tee "$system_dynamic_log" grep -Fq "cargo:rerun-if-changed=$prefix/lib/libvllm.so" \ @@ -723,19 +723,10 @@ native-capi: [[ $test_count -eq 1 ]] grep -Fxq 'Total Tests: 1' "$listing" - set +e env -u VT_ASYNC_SCHED -u VT_ASYNC_RUNNER \ ctest --test-dir "$build" --output-on-failure --tests-regex '^test_capi$' \ 2>&1 | tee "$output" - default_status=${PIPESTATUS[0]} - set -e - if [[ $default_status -eq 0 ]]; then - grep -Fxq '100% tests passed, 0 tests failed out of 1' "$output" - exit 0 - fi - - echo "default native C API gate failed (status $default_status); alternate scheduler runs cannot satisfy this gate" >&2 - exit "$default_status" + grep -Fxq '100% tests passed, 0 tests failed out of 1' "$output" # Run sys-first crates.io publication checks without uploading. publish-dry-run: diff --git a/vllm-cpp-sys/build.rs b/vllm-cpp-sys/build.rs index d9b6838..51f75a8 100644 --- a/vllm-cpp-sys/build.rs +++ b/vllm-cpp-sys/build.rs @@ -43,7 +43,7 @@ fn main() { "src/build_support.rs", "vllm.cpp/CMakeLists.txt", "vllm.cpp/cmake", - "vllm.cpp/include/vllm.h", + "vllm.cpp/include", "vllm.cpp/src", "vllm.cpp/tests/vt/test_rocm_backend.cpp", "vllm.cpp/triton_kernels", diff --git a/vllm-cpp-sys/src/build_config.rs b/vllm-cpp-sys/src/build_config.rs index 04c91b5..9b2fe6a 100644 --- a/vllm-cpp-sys/src/build_config.rs +++ b/vllm-cpp-sys/src/build_config.rs @@ -341,12 +341,6 @@ fn validate_cutlass( root.display() )) })?; - if !canonical.is_absolute() { - return Err(ConfigError::new(format!( - "VLLM_CPP_CUTLASS_DIR must resolve to an absolute path; got {}", - canonical.display() - ))); - } for relative in ["include/cutlass/cutlass.h", "include/cutlass/version.h"] { let path = canonical.join(relative); @@ -423,12 +417,6 @@ fn validate_mlx(inputs: &Inputs, probe: &impl PathProbe) -> Result` directory and creates that revision's cache ref only after a complete successful retrieval. It does not download unrelated repository assets. Offline mode constructs no network API, reads the cached `main` ref by default, and distinguishes a missing cache revision from an incomplete cached snapshot. +Both formats require a full commit SHA in online metadata and reject metadata or offline snapshots that differ from an explicit full-SHA request. Offline resolution still requires the requested revision's cache ref, even for a full SHA. + Retrieval validates cache and snapshot completeness; it does not prove that the pinned native engine supports the repository's model architecture, tokenizer, quantization, or backend. In the repository checkout, `just setup-test-model` explicitly resolves `Qwen/Qwen3-0.6B` at immutable revision `c1899de289a04d12100db370d81485cdf75e47ca`, reusing the standard Hugging Face cache and honoring normal `HF_HOME` and authentication. Ordinary tests and instrumentation commands never resolve or download this fixture. `VLLM_CPP_TEST_MODEL` remains an external contract: when supplied, it must point to a prepared model directory; model tests skip when it is unset. diff --git a/vllm-cpp/examples/README.md b/vllm-cpp/examples/README.md index c35d44c..6d2922e 100644 --- a/vllm-cpp/examples/README.md +++ b/vllm-cpp/examples/README.md @@ -40,7 +40,7 @@ The bare path remains an alias for `local`. Hub forms enable download progress, Omitting `--revision` follows the Hub's mutable default `main` branch. Use `.revision(...)` in the library or `--revision` in an example to select a branch, tag, or commit; an immutable commit SHA is recommended for reproducibility. The resolver is synchronous and always available because `hf-hub` is a normal dependency, not a Cargo feature. -By default the resolver uses Hugging Face's normal cache, honoring `HF_HOME`, and selects the cached token created by Hugging Face login. Library callers can use `.revision(...)`, `.cache_dir(...)`, `.token(...)`, `.progress(...)`, and `.offline(true)`. An explicit token overrides the selected cached token and is redacted from resolver `Debug`. Offline default resolution reads only the cached `main` ref; an explicit revision reads only that cached ref. Offline mode constructs no API. The official Hugging Face endpoint remains fixed; `HF_ENDPOINT` is not used. +By default the resolver uses Hugging Face's normal cache, honoring `HF_HOME`, and selects the cached token created by Hugging Face login. Library callers can use `.revision(...)`, `.cache_dir(...)`, `.token(...)`, `.progress(...)`, and `.offline(true)`. An explicit token overrides the selected cached token and is redacted from resolver `Debug`. Offline default resolution reads only the cached `main` ref; an explicit revision reads only that cached ref. Both formats reject online metadata or cached snapshots that differ from an explicit full-SHA request; online metadata must contain a full commit SHA. Even full-SHA offline requests require a cache ref. Offline mode constructs no API. The official Hugging Face endpoint remains fixed; `HF_ENDPOINT` is not used. GGUF mode retrieves one safe root-level filename ending in lowercase `.gguf`; split GGUF sets are unsupported. Safetensors mode queries metadata for `main` or the explicit revision, pins file retrieval to the returned commit SHA, and creates that revision's cache ref after complete success. It retrieves only `config.json`, `tokenizer.json`, optional `tokenizer_config.json`, and either root `model.safetensors` or root `model.safetensors.index.json` plus every unique root shard in `weight_map`. Unrelated repository assets are not downloaded. Successful retrieval establishes a complete sparse snapshot for the pinned loader, not model-family or backend compatibility. diff --git a/vllm-cpp/src/hf.rs b/vllm-cpp/src/hf.rs index 6bac71f..c466a2e 100644 --- a/vllm-cpp/src/hf.rs +++ b/vllm-cpp/src/hf.rs @@ -209,16 +209,7 @@ impl HuggingFaceModel { 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 sha = metadata_sha(&info, &self.revision)?; let pinned_repo = Repo::with_revision(self.repo_id.clone(), RepoType::Model, sha.to_owned()); let result = api @@ -236,13 +227,7 @@ impl HuggingFaceModel { 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}" - )) - })?; + repair_pinned_ref(cache, pinned_repo, sha)?; } let path = result?; cache @@ -262,61 +247,70 @@ impl HuggingFaceModel { .api_builder(cache.clone()) .build() .map_err(|error| hub(format!("could not create API: {error}")))?; + self.resolve_safetensors_with_api(&cache, &api) + } + + fn resolve_safetensors_with_api( + &self, + cache: &Cache, + api: &Api, + ) -> 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(); - if sha.is_empty() { - return Err(incomplete("repository metadata has an empty commit SHA")); - } - validate_root_filename(sha, "repository metadata SHA") - .map_err(|_| incomplete("repository metadata has an unsafe SHA"))?; + let sha = metadata_sha(&info, &self.revision)?; let available = sibling_names(&info); let bootstrap = plan_safetensors(&available)?; let pinned_repo = Repo::with_revision(self.repo_id.clone(), RepoType::Model, sha.to_owned()); let pinned = api.repo(pinned_repo.clone()); - let index = if bootstrap.indexed { - let path = pinned.get(SAFETENSORS_INDEX).map_err(|error| { - hub(format!( - "could not resolve `{SAFETENSORS_INDEX}` for `{}` at `{sha}`: {error}", - self.repo_id - )) - })?; - let bytes = fs::read(&path).map_err(|error| { - io_error(format!("could not read `{}`: {error}", path.display())) - })?; - Some((path, bytes)) - } else { - None - }; - let (plan, index_path) = match index { - Some((path, bytes)) => (plan_indexed_safetensors(&available, &bytes)?, Some(path)), - None => (bootstrap, None), - }; - - let mut paths = HashMap::new(); - if let Some(path) = index_path { - paths.insert(SAFETENSORS_INDEX.to_owned(), path); - } - for filename in &plan.files { - if paths.contains_key(filename) { - continue; + let result = (|| { + let index = if bootstrap.indexed { + let path = pinned.get(SAFETENSORS_INDEX).map_err(|error| { + hub(format!( + "could not resolve `{SAFETENSORS_INDEX}` for `{}` at `{sha}`: {error}", + self.repo_id + )) + })?; + let bytes = fs::read(&path).map_err(|error| { + io_error(format!("could not read `{}`: {error}", path.display())) + })?; + Some((path, bytes)) + } else { + None + }; + let (plan, index_path) = match index { + Some((path, bytes)) => (plan_indexed_safetensors(&available, &bytes)?, Some(path)), + None => (bootstrap, None), + }; + + let mut paths = HashMap::new(); + if let Some(path) = index_path { + paths.insert(SAFETENSORS_INDEX.to_owned(), path); + } + for filename in &plan.files { + if paths.contains_key(filename) { + continue; + } + let path = pinned.get(filename).map_err(|error| { + hub(format!( + "could not resolve `{filename}` for `{}` at `{sha}`: {error}", + self.repo_id + )) + })?; + paths.insert(filename.clone(), path); } - let path = pinned.get(filename).map_err(|error| { - hub(format!( - "could not resolve `{filename}` for `{}` at `{sha}`: {error}", - self.repo_id - )) - })?; - paths.insert(filename.clone(), path); - } - let snapshot = verify_snapshot_paths(&paths, sha)?; + verify_snapshot_paths(&paths, sha) + })(); + if result.is_err() { + repair_pinned_ref(cache, pinned_repo, sha)?; + } + let snapshot = result?; cache .repo(self.requested_repo()) .create_ref(sha) @@ -340,13 +334,6 @@ 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!( @@ -536,9 +523,43 @@ fn snapshot_for_cached_revision(cache: &Cache, repo: &Repo) -> Result(info: &'a RepoInfo, revision: &str) -> Result<&'a str, HuggingFaceError> { + 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")); + } + verify_requested_commit(revision, sha, "repository metadata")?; + Ok(sha) +} + +fn verify_requested_commit( + revision: &str, + sha: &str, + source: &str, +) -> Result<(), HuggingFaceError> { + if is_full_commit_sha(revision) && !sha.eq_ignore_ascii_case(revision) { + return Err(incomplete(format!( + "{source} differs from the requested commit" + ))); + } + Ok(()) +} + +fn repair_pinned_ref(cache: &Cache, repo: Repo, sha: &str) -> Result<(), HuggingFaceError> { + // hf-hub writes refs/ before returning, even for a wrong snapshot. + // Restore only that immutable identity, never a stale mutable alias. + cache.repo(repo).create_ref(sha).map_err(|error| { + io_error(format!( + "could not restore pinned cache ref `{sha}`: {error}" + )) + }) +} + fn verify_gguf_path(path: &Path, filename: &str) -> Result<(), HuggingFaceError> { if !path.is_file() { return Err(incomplete(format!( @@ -813,6 +834,20 @@ mod tests { info_sha: &str, download_sha: &str, hook: impl FnOnce() + Send + 'static, + ) -> Self { + Self::with_files_and_hook( + info_sha, + download_sha, + &[("model.gguf", "revision-a")], + hook, + ) + } + + fn with_files_and_hook( + info_sha: &str, + download_sha: &str, + files: &[(&str, &str)], + hook: impl FnOnce() + Send + 'static, ) -> Self { use std::io::{BufRead, Write}; use std::sync::{atomic::AtomicBool, Arc, Mutex}; @@ -827,6 +862,10 @@ mod tests { let done = Arc::clone(&stop); let info_sha = info_sha.to_owned(); let download_sha = download_sha.to_owned(); + let files: HashMap = files + .iter() + .map(|(name, body)| ((*name).to_owned(), (*body).to_owned())) + .collect(); let mut hook = Some(hook); let worker = std::thread::spawn(move || { let deadline = Instant::now() + Duration::from_secs(10); @@ -863,9 +902,11 @@ mod tests { } } seen.lock().unwrap().push((path.clone(), range.clone())); + let filename = path.rsplit('/').next().unwrap(); + let content = files.get(filename).map(String::as_str).unwrap_or(""); let (body, headers) = if path.starts_with("/api/") { ( - serde_json::json!({"sha": info_sha, "siblings": [{"rfilename": "model.gguf"}]}).to_string(), + serde_json::json!({"sha": info_sha, "siblings": files.keys().map(|name| serde_json::json!({"rfilename": name})).collect::>()} ).to_string(), "Content-Type: application/json\r\n".to_owned(), ) } else if range == "bytes=0-0" { @@ -874,18 +915,24 @@ mod tests { } ( "r".to_owned(), - format!("x-repo-commit: {download_sha}\r\netag: \"blob-a\"\r\nContent-Range: bytes 0-0/10\r\n"), + format!("x-repo-commit: {download_sha}\r\netag: \"blob-{filename}\"\r\nContent-Range: bytes 0-0/{}\r\n", content.len()), ) } else { // A mutable branch has moved since metadata was fetched. - let body = if path.contains(COMMIT) { - "revision-a" - } else { + let body = if filename == "model.gguf" && !path.contains(COMMIT) { "revision-b" + } else { + content }; (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(); + // Empty mock bodies deliberately simulate a missing download. + let status = if !path.starts_with("/api/") && content.is_empty() { + "404 Not Found" + } else { + "200 OK" + }; + write!(stream, "HTTP/1.1 {status}\r\nConnection: close\r\nContent-Length: {}\r\n{headers}\r\n{body}", body.len()).unwrap(); } }); Self { @@ -1153,6 +1200,240 @@ mod tests { ); } + fn safetensors_files(indexed: bool) -> Vec<(&'static str, &'static str)> { + let mut files = vec![(CONFIG, "config"), (TOKENIZER, "tokenizer")]; + if indexed { + files.extend([ + ( + SAFETENSORS_INDEX, + r#"{"weight_map":{"x":"part.safetensors"}}"#, + ), + ("part.safetensors", "weights"), + ]); + } else { + files.push((SAFETENSORS, "weights")); + } + files + } + + fn safetensors_wrong_offline_ref(indexed: bool) { + let temp = TempDir::new(); + let cache = Cache::new(temp.0.clone()); + let model = HuggingFaceModel::safetensors(REPO) + .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(); + for (filename, contents) in safetensors_files(indexed) { + fs::write(snapshot.join(filename), contents).unwrap(); + } + assert_incomplete_contains(model.resolve(), "requested commit"); + } + + #[test] + fn safetensors_explicit_sha_offline_rejects_complete_unsharded_wrong_ref() { + safetensors_wrong_offline_ref(false); + } + + #[test] + fn safetensors_explicit_sha_offline_rejects_complete_indexed_wrong_ref() { + safetensors_wrong_offline_ref(true); + } + + #[test] + fn safetensors_explicit_sha_offline_accepts_matching_snapshot_but_requires_ref() { + for indexed in [false, true] { + let temp = TempDir::new(); + let cache = Cache::new(temp.0.clone()); + let model = HuggingFaceModel::safetensors(REPO) + .revision(COMMIT.to_ascii_uppercase()) + .cache_dir(&temp.0) + .offline(true); + let requested = cache.repo(model.requested_repo()); + requested.create_ref(COMMIT).unwrap(); + let snapshot = requested.pointer_path(COMMIT); + fs::create_dir_all(&snapshot).unwrap(); + for (filename, contents) in safetensors_files(indexed) { + fs::write(snapshot.join(filename), contents).unwrap(); + } + assert_eq!(model.resolve().unwrap(), snapshot); + fs::remove_file( + temp.0 + .join("models--owner--model/refs") + .join(&model.revision), + ) + .unwrap(); + assert!(matches!( + model.resolve(), + Err(HuggingFaceError::CacheMiss { .. }) + )); + } + } + + #[test] + fn safetensors_online_rejects_invalid_or_different_metadata_before_download_or_ref_write() { + for sha in [MOVED, "", "../unsafe", "main"] { + let temp = TempDir::new(); + let cache = Cache::new(temp.0.clone()); + let model = HuggingFaceModel::safetensors(REPO) + .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(); + for (filename, contents) in safetensors_files(false) { + fs::write(snapshot.join(filename), contents).unwrap(); + } + let server = MockHub::with_files_and_hook(sha, MOVED, &safetensors_files(false), || {}); + assert_incomplete_contains( + model.resolve_safetensors_with_api(&cache, &server.api(cache.clone())), + if sha == MOVED { + "requested commit" + } else { + "invalid commit SHA" + }, + ); + assert_eq!(server.requests.lock().unwrap().len(), 1); + assert_eq!(model.offline(true).resolve().unwrap(), snapshot); + assert_eq!(fs::read(snapshot.join(SAFETENSORS)).unwrap(), b"weights"); + assert!(!temp + .0 + .join("models--owner--model/refs") + .join(MOVED) + .exists()); + } + } + + #[test] + fn safetensors_online_pins_alias_and_explicit_sha_reuses_cache_and_resolves_offline() { + for indexed in [false, true] { + for revision in [REVISION.to_owned(), COMMIT.to_ascii_uppercase()] { + let temp = TempDir::new(); + let cache = Cache::new(temp.0.clone()); + let files = safetensors_files(indexed); + let server = MockHub::with_files_and_hook(COMMIT, COMMIT, &files, || {}); + let model = HuggingFaceModel::safetensors(REPO) + .revision(revision) + .cache_dir(&temp.0); + let api = server.api(cache.clone()); + let snapshot = model.resolve_safetensors_with_api(&cache, &api).unwrap(); + assert_eq!(snapshot_sha(&snapshot).unwrap(), COMMIT); + for (filename, contents) in &files { + assert_eq!( + fs::read(snapshot.join(filename)).unwrap(), + contents.as_bytes() + ); + } + let requests = server.requests.lock().unwrap().clone(); + assert_eq!(requests.len(), 1 + 2 * files.len()); + assert!(requests[1..] + .iter() + .all(|(path, _)| path.starts_with(&format!("/{REPO}/resolve/{COMMIT}/")))); + assert_eq!( + model.resolve_safetensors_with_api(&cache, &api).unwrap(), + snapshot + ); + assert_eq!(server.requests.lock().unwrap().len(), requests.len() + 1); + assert_eq!(model.offline(true).resolve().unwrap(), snapshot); + } + } + } + + fn safetensors_rejected_multifile(failure: &str) { + for revision in [COMMIT, REVISION] { + let temp = TempDir::new(); + let cache = Cache::new(temp.0.clone()); + let model = HuggingFaceModel::safetensors(REPO) + .revision(revision) + .cache_dir(&temp.0); + let requested = cache.repo(model.requested_repo()); + requested.create_ref(COMMIT).unwrap(); + let good = requested.pointer_path(COMMIT); + fs::create_dir_all(&good).unwrap(); + fs::write(good.join(CONFIG), "keep-config").unwrap(); + fs::write(good.join("other.gguf"), "keep-other").unwrap(); + let mut files = safetensors_files(failure == "index"); + if failure == "index" { + files + .iter_mut() + .find(|(name, _)| *name == SAFETENSORS_INDEX) + .unwrap() + .1 = "invalid JSON"; + } else if failure == "get" { + files + .iter_mut() + .find(|(name, _)| *name == SAFETENSORS) + .unwrap() + .1 = ""; + } + let server = MockHub::with_files_and_hook(COMMIT, MOVED, &files, || {}); + let result = model.resolve_safetensors_with_api(&cache, &server.api(cache.clone())); + match failure { + "snapshot" => assert_incomplete_contains(result, "snapshots/"), + "index" => assert_incomplete_contains(result, "malformed JSON"), + _ => assert!(matches!(result, Err(HuggingFaceError::Hub { .. }))), + } + let refs = temp.0.join("models--owner--model/refs"); + assert_eq!(fs::read_to_string(refs.join(COMMIT)).unwrap(), COMMIT); + assert_eq!(fs::read_to_string(refs.join(revision)).unwrap(), COMMIT); + assert_eq!(fs::read(good.join(CONFIG)).unwrap(), b"keep-config"); + let other = HuggingFaceModel::gguf(REPO, "other.gguf") + .revision(COMMIT) + .cache_dir(&temp.0) + .offline(true); + assert_eq!(fs::read(other.resolve().unwrap()).unwrap(), b"keep-other"); + assert!(model.offline(true).resolve().is_err()); + } + } + + #[test] + fn safetensors_mixed_snapshot_repairs_only_pinned_identity() { + safetensors_rejected_multifile("snapshot"); + } + + #[test] + fn safetensors_invalid_downloaded_index_repairs_only_pinned_identity() { + safetensors_rejected_multifile("index"); + } + + #[test] + fn safetensors_later_get_failure_repairs_only_pinned_identity() { + safetensors_rejected_multifile("get"); + } + + #[test] + fn safetensors_rejected_download_does_not_roll_back_newer_mutable_alias() { + let temp = TempDir::new(); + let cache = Cache::new(temp.0.clone()); + let model = HuggingFaceModel::safetensors(REPO) + .revision(REVISION) + .cache_dir(&temp.0); + let requested = cache.repo(model.requested_repo()); + requested.create_ref(COMMIT).unwrap(); + let server = + MockHub::with_files_and_hook(COMMIT, MOVED, &safetensors_files(false), move || { + requested.create_ref(MOVED).unwrap(); + }); + assert_incomplete_contains( + model.resolve_safetensors_with_api(&cache, &server.api(cache.clone())), + "snapshots/", + ); + assert_eq!( + fs::read_to_string(temp.0.join("models--owner--model/refs").join(REVISION)).unwrap(), + MOVED + ); + // The complete newer snapshot is usable through the alias, not through the rejected pin. + assert_eq!( + snapshot_sha(&model.clone().offline(true).resolve().unwrap()).unwrap(), + MOVED + ); + assert!(model.revision(COMMIT).offline(true).resolve().is_err()); + } + #[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 ddb5a54..a63daa6 100644 --- a/vllm-cpp/src/params.rs +++ b/vllm-cpp/src/params.rs @@ -382,7 +382,7 @@ impl MarshaledSamplingParams { let mut logits_processor = None; if let Some(processor) = ¶ms.logits_processor { - if params.max_tokens.is_none() || params.max_tokens == Some(0) { + if params.max_tokens.is_none() { return Err(invalid_configuration( "custom logits processors require bounded max_tokens because the native callback cannot abort generation", )); diff --git a/vllm-cpp/tests/qwen3.rs b/vllm-cpp/tests/qwen3.rs index 83ae2ea..4f84950 100644 --- a/vllm-cpp/tests/qwen3.rs +++ b/vllm-cpp/tests/qwen3.rs @@ -79,134 +79,40 @@ struct FixtureWav { } fn decode_pcm16_mono_wav(bytes: &[u8]) -> Result { - if bytes.len() < 12 { - return Err("truncated RIFF/WAVE header".to_owned()); + // parakeet_e2e_fixture_gen.py emits exactly this 44-byte PCM WAV header. + if bytes.len() < 44 + || &bytes[..4] != b"RIFF" + || &bytes[8..16] != b"WAVEfmt " + || &bytes[36..40] != b"data" + { + return Err("unexpected fixture WAV layout".to_owned()); } - if &bytes[..4] != b"RIFF" { - return Err("missing RIFF identifier".to_owned()); + let u32_at = |offset| u32::from_le_bytes(bytes[offset..offset + 4].try_into().unwrap()); + let u16_at = |offset| u16::from_le_bytes(bytes[offset..offset + 2].try_into().unwrap()); + if u32_at(16) != 16 + || u16_at(20) != 1 + || u16_at(22) != 1 + || u32_at(24) != 16_000 + || u32_at(28) != 32_000 + || u16_at(32) != 2 + || u16_at(34) != 16 + { + return Err("fixture WAV must be mono PCM16 at 16000 Hz".to_owned()); } - if &bytes[8..12] != b"WAVE" { - return Err("missing WAVE identifier".to_owned()); + let data = &bytes[44..]; + if data.is_empty() + || data.len() % 2 != 0 + || u64::from(u32_at(4)) != (bytes.len() - 8) as u64 + || u64::from(u32_at(40)) != data.len() as u64 + { + return Err("invalid fixture WAV lengths".to_owned()); } - - let riff_size = usize::try_from(u32::from_le_bytes( - bytes[4..8] - .try_into() - .map_err(|_| "truncated RIFF size".to_owned())?, - )) - .map_err(|_| "RIFF size exceeds address space".to_owned())?; - let riff_end = 8usize - .checked_add(riff_size) - .ok_or_else(|| "RIFF size overflows address space".to_owned())?; - if riff_end < 12 { - return Err("RIFF size does not include the WAVE identifier".to_owned()); - } - if riff_end > bytes.len() { - return Err("declared RIFF end exceeds input length".to_owned()); - } - if riff_end != bytes.len() { - return Err("bytes remain after the declared RIFF end".to_owned()); - } - - let mut fmt = None; - let mut data = None; - let mut offset = 12usize; - while offset < riff_end { - let header_end = offset - .checked_add(8) - .ok_or_else(|| "chunk header offset overflows address space".to_owned())?; - if header_end > riff_end { - return Err(format!("truncated chunk header at byte {offset}")); - } - let name: [u8; 4] = bytes[offset..offset + 4] - .try_into() - .map_err(|_| format!("truncated chunk identifier at byte {offset}"))?; - let size = usize::try_from(u32::from_le_bytes( - bytes[offset + 4..header_end] - .try_into() - .map_err(|_| format!("truncated chunk size at byte {offset}"))?, - )) - .map_err(|_| format!("chunk length exceeds address space at byte {offset}"))?; - let body_end = header_end - .checked_add(size) - .ok_or_else(|| format!("chunk length overflows address space at byte {offset}"))?; - if body_end > riff_end { - return Err(format!("chunk length exceeds RIFF bounds at byte {offset}")); - } - let padded_end = body_end - .checked_add(size & 1) - .ok_or_else(|| format!("chunk padding overflows address space at byte {offset}"))?; - if padded_end > riff_end { - return Err(format!("missing padding byte for chunk at byte {offset}")); - } - - match &name { - b"fmt " => { - if fmt.is_some() { - return Err("duplicate fmt chunk".to_owned()); - } - if size < 16 { - return Err(format!("fmt chunk is too short: {size} bytes")); - } - let body = &bytes[header_end..body_end]; - let format = u16::from_le_bytes([body[0], body[1]]); - let channels = u16::from_le_bytes([body[2], body[3]]); - let sample_rate = u32::from_le_bytes(body[4..8].try_into().unwrap()); - let byte_rate = u32::from_le_bytes(body[8..12].try_into().unwrap()); - let block_align = u16::from_le_bytes([body[12], body[13]]); - let bits_per_sample = u16::from_le_bytes([body[14], body[15]]); - if format != 1 { - return Err(format!("WAV format code must be PCM 1, found {format}")); - } - if channels != 1 { - return Err(format!("WAV must be mono, found {channels} channels")); - } - if sample_rate != 16_000 { - return Err(format!( - "WAV sample rate must be 16000, found {sample_rate}" - )); - } - if bits_per_sample != 16 { - return Err(format!( - "WAV sample width must be 16 bits, found {bits_per_sample}" - )); - } - if block_align != 2 { - return Err(format!( - "WAV block alignment must be 2, found {block_align}" - )); - } - if byte_rate != 32_000 { - return Err(format!("WAV byte rate must be 32000, found {byte_rate}")); - } - fmt = Some(sample_rate); - } - b"data" => { - if data.is_some() { - return Err("duplicate data chunk".to_owned()); - } - if size == 0 { - return Err("WAV data chunk is empty".to_owned()); - } - if size % 2 != 0 { - return Err(format!("WAV data chunk has odd size {size}")); - } - data = Some((header_end, body_end)); - } - _ => {} - } - offset = padded_end; - } - - let sample_rate = fmt.ok_or_else(|| "missing fmt chunk".to_owned())?; - let (data_start, data_end) = data.ok_or_else(|| "missing data chunk".to_owned())?; - let samples = bytes[data_start..data_end] - .chunks_exact(2) - .map(|sample| i16::from_le_bytes([sample[0], sample[1]]) as f32 / 32768.0) - .collect(); Ok(FixtureWav { - samples, - sample_rate, + samples: data + .chunks_exact(2) + .map(|sample| i16::from_le_bytes([sample[0], sample[1]]) as f32 / 32768.0) + .collect(), + sample_rate: 16_000, }) } @@ -216,173 +122,24 @@ fn read_pcm16_mono_wav(path: &Path) -> Result { decode_pcm16_mono_wav(&bytes) } -#[cfg(test)] -fn fixture_fmt_chunk() -> Vec { - let mut fmt = Vec::new(); - fmt.extend_from_slice(&1u16.to_le_bytes()); - fmt.extend_from_slice(&1u16.to_le_bytes()); - fmt.extend_from_slice(&16_000u32.to_le_bytes()); - fmt.extend_from_slice(&32_000u32.to_le_bytes()); - fmt.extend_from_slice(&2u16.to_le_bytes()); - fmt.extend_from_slice(&16u16.to_le_bytes()); - fmt -} - -#[cfg(test)] -fn fixture_wav_bytes(chunks: &[([u8; 4], Vec)]) -> Vec { - let mut body = b"WAVE".to_vec(); - for (name, chunk) in chunks { - body.extend_from_slice(name); - body.extend_from_slice(&(chunk.len() as u32).to_le_bytes()); - body.extend_from_slice(chunk); - if chunk.len() % 2 != 0 { - body.push(0); - } - } - let mut bytes = b"RIFF".to_vec(); - bytes.extend_from_slice(&(body.len() as u32).to_le_bytes()); - bytes.extend_from_slice(&body); - bytes -} - -#[cfg(test)] -fn assert_wav_error(bytes: &[u8], expected: &str) { - let error = decode_pcm16_mono_wav(bytes).expect_err("malformed WAV must fail"); - assert!( - error.contains(expected), - "expected {expected:?} in WAV error {error:?}" - ); -} - -#[test] -fn wav_parser_accepts_unknown_padding_and_data_before_fmt() { - let data = [i16::MIN.to_le_bytes(), 0i16.to_le_bytes()].concat(); - let bytes = fixture_wav_bytes(&[ - (*b"JUNK", vec![7]), - (*b"data", data), - (*b"fmt ", fixture_fmt_chunk()), - ]); - let wav = decode_pcm16_mono_wav(&bytes).expect("valid PCM fixture WAV"); - assert_eq!(wav.sample_rate, 16_000); - assert_eq!(wav.samples, [-1.0, 0.0]); -} - -#[test] -fn wav_parser_rejects_identifiers_and_riff_bounds() { - assert_wav_error(&[], "truncated RIFF/WAVE header"); - - let valid = fixture_wav_bytes(&[(*b"fmt ", fixture_fmt_chunk()), (*b"data", vec![0, 0])]); - let mut wrong_riff = valid.clone(); - wrong_riff[..4].copy_from_slice(b"RIFX"); - assert_wav_error(&wrong_riff, "missing RIFF identifier"); - let mut wrong_wave = valid.clone(); - wrong_wave[8..12].copy_from_slice(b"WVAE"); - assert_wav_error(&wrong_wave, "missing WAVE identifier"); - - let mut short_riff = valid.clone(); - short_riff[4..8].copy_from_slice(&3u32.to_le_bytes()); - assert_wav_error( - &short_riff, - "RIFF size does not include the WAVE identifier", - ); - - let mut truncated = valid.clone(); - let declared = u32::from_le_bytes(truncated[4..8].try_into().unwrap()); - truncated[4..8].copy_from_slice(&(declared + 1).to_le_bytes()); - assert_wav_error(&truncated, "declared RIFF end exceeds input length"); - - let mut trailing = valid; - trailing.push(0); - assert_wav_error(&trailing, "bytes remain after the declared RIFF end"); -} - #[test] -fn wav_parser_rejects_truncated_chunks_and_padding() { - let mut header = b"RIFF".to_vec(); - header.extend_from_slice(&7u32.to_le_bytes()); - header.extend_from_slice(b"WAVEabc"); - assert_wav_error(&header, "truncated chunk header"); - - let mut body = b"WAVEdata".to_vec(); - body.extend_from_slice(&4u32.to_le_bytes()); - body.extend_from_slice(&[0, 0]); - let mut truncated_body = b"RIFF".to_vec(); - truncated_body.extend_from_slice(&(body.len() as u32).to_le_bytes()); - truncated_body.extend_from_slice(&body); - assert_wav_error(&truncated_body, "chunk length exceeds RIFF bounds"); - - let mut odd_body = b"WAVEJUNK".to_vec(); - odd_body.extend_from_slice(&1u32.to_le_bytes()); - odd_body.push(7); - let mut missing_padding = b"RIFF".to_vec(); - missing_padding.extend_from_slice(&(odd_body.len() as u32).to_le_bytes()); - missing_padding.extend_from_slice(&odd_body); - assert_wav_error(&missing_padding, "missing padding byte"); - - let mut huge_body = b"WAVEdata".to_vec(); - huge_body.extend_from_slice(&u32::MAX.to_le_bytes()); - let mut huge = b"RIFF".to_vec(); - huge.extend_from_slice(&(huge_body.len() as u32).to_le_bytes()); - huge.extend_from_slice(&huge_body); - assert_wav_error(&huge, "chunk length exceeds RIFF bounds"); -} - -#[test] -fn wav_parser_rejects_invalid_format_metadata() { - let cases = [ - (0usize, 3u16.to_le_bytes().to_vec(), "format code"), - (2, 2u16.to_le_bytes().to_vec(), "mono"), - (4, 8_000u32.to_le_bytes().to_vec(), "sample rate"), - (8, 16_000u32.to_le_bytes().to_vec(), "byte rate"), - (12, 4u16.to_le_bytes().to_vec(), "block alignment"), - (14, 8u16.to_le_bytes().to_vec(), "sample width"), - ]; - for (offset, replacement, expected) in cases { - let mut fmt = fixture_fmt_chunk(); - fmt[offset..offset + replacement.len()].copy_from_slice(&replacement); - let bytes = fixture_wav_bytes(&[(*b"fmt ", fmt), (*b"data", vec![0, 0])]); - assert_wav_error(&bytes, expected); +fn fixture_wav_rejects_bad_header_and_lengths() { + let Some(path) = native_fixture("parakeet_e2e/audio.wav") else { + return; + }; + let bytes = std::fs::read(path).expect("read committed fixture WAV"); + assert!(decode_pcm16_mono_wav(&bytes).is_ok()); + for offset in [0, 4, 8, 12, 16, 20, 22, 24, 28, 32, 34, 36, 40] { + let mut invalid = bytes.clone(); + invalid[offset] ^= 1; + assert!(decode_pcm16_mono_wav(&invalid).is_err(), "byte {offset}"); } - - let short = fixture_wav_bytes(&[(*b"fmt ", vec![0; 15]), (*b"data", vec![0, 0])]); - assert_wav_error(&short, "fmt chunk is too short"); -} - -#[test] -fn wav_parser_rejects_missing_duplicate_and_invalid_data_chunks() { - let fmt = fixture_fmt_chunk(); - assert_wav_error( - &fixture_wav_bytes(&[(*b"data", vec![0, 0])]), - "missing fmt chunk", - ); - assert_wav_error( - &fixture_wav_bytes(&[(*b"fmt ", fmt.clone())]), - "missing data chunk", - ); - assert_wav_error( - &fixture_wav_bytes(&[ - (*b"fmt ", fmt.clone()), - (*b"fmt ", fmt.clone()), - (*b"data", vec![0, 0]), - ]), - "duplicate fmt chunk", - ); - assert_wav_error( - &fixture_wav_bytes(&[ - (*b"fmt ", fmt.clone()), - (*b"data", vec![0, 0]), - (*b"data", vec![0, 0]), - ]), - "duplicate data chunk", - ); - assert_wav_error( - &fixture_wav_bytes(&[(*b"fmt ", fmt.clone()), (*b"data", Vec::new())]), - "data chunk is empty", - ); - assert_wav_error( - &fixture_wav_bytes(&[(*b"fmt ", fmt), (*b"data", vec![0])]), - "data chunk has odd size", - ); + for length in [0, 43, 44, bytes.len() - 1] { + assert!(decode_pcm16_mono_wav(&bytes[..length]).is_err()); + } + let mut trailing = bytes; + trailing.push(0); + assert!(decode_pcm16_mono_wav(&trailing).is_err()); } #[test]