Studio: fix the permanent GGUF "update available" on no-symlink caches - #7113
Conversation
Without the symlink privilege (the default on Windows with Developer Mode OFF), hf_hub_download MOVES the downloaded blob into snapshots/ instead of symlinking it out of blobs/, so blobs/ is left empty and scan_cache_dir reports blob_path = the snapshot file itself. Path(blob_path).name is then the GGUF FILENAME, not the file's etag. _repo_gguf_blob_map recorded that filename as the file's local blob hash, so _variant_update_available_from_requirement's `remote_sha256 in local_set` test could never match and every cached GGUF reported "update available" forever. Re-downloading could not clear it: the same file is rewritten, still with no blob. Only treat Path(blob_path).name as a hash when the file really lives in the cache's blobs/ dir; otherwise record a size identity so the file still appears in the map (dropping it would make the update check read it as absent and report the same phantom update). The comparison falls back to the remote ExpectedFile.size only when the cached file carries no blob hash, so the blob-hash path is unchanged wherever HF does produce blobs. A remote requant that keeps the byte size identical is not detected in that layout; re-hashing multi-GB GGUFs on the inventory hot path is the only stricter option. Fixes unslothai#7060
There was a problem hiding this comment.
Code Review
This pull request resolves an issue on Windows systems without Developer Mode where the lack of symlink privileges causes Hugging Face to move files directly into snapshots/ instead of blobs/, leading to persistent phantom 'Update available' notifications. To fix this, a size-based identity fallback (size:<bytes>) is introduced for cached files when a blob hash is unavailable, allowing remote-vs-local comparisons to match based on file size. Comprehensive unit tests have been added to validate this behavior. There are no review comments, so no further feedback is provided.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
ea96a51 to
97a6a9f
Compare
There was a problem hiding this comment.
๐ก Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 97a6a9ff27
โน๏ธ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with ๐.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| comparison miss, so it is reported as "no blob" instead. | ||
| """ | ||
| path = Path(blob_path) | ||
| return path.name if path.parent.name == "blobs" else None |
There was a problem hiding this comment.
Check the actual cache blobs directory
In no-symlink caches, blob_path is the snapshot path; if a repository stores GGUFs under a subdirectory named blobs/ (for example snapshots/<rev>/blobs/model-Q4_K_M.gguf), this check classifies the snapshot file as a cache blob just because its immediate parent is named blobs. _repo_gguf_blob_map then records the filename instead of a size: identity, so the remote SHA still never matches and the permanent update badge/re-download loop remains for that valid repo layout. Compare against the repo cache's top-level blobs directory or explicitly reject paths under snapshots/.
Useful? React with ๐ย / ๐.
There was a problem hiding this comment.
No real GGUF repo ships a top-level blobs/ directory, so this needs a contrived repo layout plus a no-symlink cache to trigger, and the pre-PR code already returned Path(blob_path).name here unconditionally, so it is not a regression. The suggested reject-under-snapshots/ check also misfires whenever the cache root itself contains a snapshots path component (for example HF_HOME=/data/snapshots/hf), demoting every real symlinked blob to a size identity, which is a genuine regression on valid input. Anchoring to the repo-cache top-level blobs dir would need repo_path threaded through for input that does not occur, so leaving as is.
โฆl-size requant blind spot (unslothai#7060) On a no-symlink cache (Windows without Developer Mode) blobs/ is empty, so the update check falls back to comparing byte size. A Studio download records each file's sha256 in its manifest, so feed that into the local identity set: the check can then match by hash and detect an equal-size requant. The size fallback now applies only when no real hash is present, so a manifest hash that differs is still reported as a genuine update. Adds regression tests.
|
Pushed a follow-up so the update check prefers the exact hash over size on a no-symlink cache. Studio records each downloaded file's sha256 in the download manifest, so even when
The manifest read is gated behind Added three regression tests in |
for more information, see https://pre-commit.ci
โฆlback (unslothai#7060) The download manifest is written before the transfer with the expected remote hashes, so it records download intent, not verified on-disk content, and completion is checked by size only. Merging those hashes into the local identity set could clear the update badge for an interrupted equal-size update that left the old bytes on disk. The accompanying all-size-identity gate also suppressed the size fallback whenever an older revision contributed a real blob hash, which re-showed a false update on mixed hash and size caches. Restoring the plain size-identity fallback keeps the fix without those regressions.
|
Reverted my manifest-sha256 change in 0e00390. On a closer look the download manifest is written before the transfer with the expected remote hashes (hf_download.py The |
โฆnslothai#7060) On a no-symlink cache (Windows without Developer Mode) the downloaded file is moved into snapshots/ and scan_cache_dir reports its blob_path as that snapshot file, whose name is the filename, not an etag. reclaim_replaced_gguf_variant treated that name as a blob hash, which never matches the current hashes to keep, so it unlinked the freshly downloaded file. Only extract a deletable hash when the blob path is a real cache blob under the repo blobs directory, and keep any file we cannot identify; stale no-symlink revisions leak rather than risk removing the current file. Adds a regression test.
|
While reviewing this I found a related data-loss bug on the same no-symlink layout and fixed it in 8141ec6. After a verified GGUF download the worker calls The fix only extracts a deletable hash when the blob path is a real cache blob directly under the repo |
|
@codex review |
There was a problem hiding this comment.
๐ก Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8141ec6f67
โน๏ธ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with ๐.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
โฆnslothai#7060) The inventory update check and the stale-variant reclaim both decide whether a scanned file is a real cache blob (name is the etag) or a moved no-symlink snapshot file (name is the filename). Both now share one _is_real_cache_blob helper that anchors to the repo cache blobs directory instead of matching any parent folder named blobs, so a repo that ships GGUFs under its own blobs subdir is no longer misread as the cache blob store. Threads repo_path through _repo_gguf_blob_map. Adds a regression test.
for more information, see https://pre-commit.ci
|
@codex review |
|
Codex Review: Didn't find any major issues. Chef's kiss. Reviewed commit: โน๏ธ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with ๐. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
โฆ7060) Post-review comment pass: shorten the internal blob-identity and size-fallback docstrings. Comments only, no behavior change.
|
@codex review |
|
Codex Review: Didn't find any major issues. Swish! Reviewed commit: โน๏ธ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with ๐. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
Summary
On Windows with Developer Mode off (the default),
huggingface_hubhas no symlink privilege, sohf_hub_downloadmoves the downloaded blob intosnapshots/instead of symlinking it out ofblobs/.blobs/is left empty andscan_cache_dirreportsblob_path= the snapshot file itself โ soPath(blob_path).nameis the GGUF filename, not the file's etag._repo_gguf_blob_maprecorded that filename as the file's local blob hash, so theremote_sha256 in local_settest in_variant_update_available_from_requirementcould never match. Every cached GGUF reportedupdate availableforever, and re-downloading could not clear it โ the same file is rewritten, still with no blob.Per #7060 this is not just a cosmetic badge: a second reporter notes the model gets re-downloaded instead of loaded from cache, which makes Studio unusable on a default Windows install.
The fix:
_cached_blob_hash()treatsPath(blob_path).nameas a hash only when the file really lives in the cache'sblobs/dir (the layout invariant this module already relies on)._repo_gguf_blob_maprecords a size identity (size:<bytes>) instead. It has to stay in the map โ dropping it would make the update check read the file as absent and report the same phantom update._variant_update_available_from_requirementfalls back to the remoteExpectedFile.sizeonly when the cached file carries no blob hash. This is deliberately asymmetric: asize:token can only ever appear for a file with no blob, so the blob-hash path is untouched wherever HF does produce blobs (Linux, macOS, and Windows with Developer Mode on).Known tradeoff, called out explicitly: in the no-symlink layout an upstream requant that leaves the byte size identical is not detected. The only stricter option is re-hashing multi-GB GGUFs on the inventory hot path. The status quo is wrong for every GGUF and unfixable by re-downloading, so this is a strict improvement โ happy to switch to lazy hashing if you'd prefer.
Validation
Ran locally (macOS, CPU โ no GPU required for this path):
python -m pytest tests/ -qfromstudio/backend(CI's invocation): 6369 passed, vs 6363 passed on a cleanmainwith the identical command โ exactly the +6 new tests, with the pre-existing failure/error counts unchanged (82 failed / 70 errorsboth before and after; all fromtorch/llama-server/network deps absent in my local env, not from this change).python -m pytest tests/test_model_update_robustness.py tests/test_cached_gguf_routes.py -qโ 40 passed (the two suites covering the touched code).ruff check .(repo config,ruff==0.15.12) โ All checks passed!Qwen3-1.7B-Q2_K.ggufliving directly insnapshots/withblobs/empty now yields identitysize:778000000, matches the remote size, and reportsupdate_available = False; bumping the remote size still reportsTrue.Could not run locally, and am relying on CI for: the Windows job matrix and the real end-to-end Windows/HF-cache path. I do not have a Windows box with Developer Mode off, so the no-symlink layout is exercised through the unit tests and the module-level repro above rather than against a live cache.
New tests in
studio/backend/tests/test_model_update_robustness.py:test_repo_gguf_blob_map_uses_size_identity_when_cache_has_no_blobtest_repo_gguf_blob_map_skips_snapshot_file_with_unknown_sizetest_no_symlink_cache_matching_remote_size_reports_no_update(the GGUF quants permanently show "update available" on Windows (no-symlink cache)ย #7060 repro)test_no_symlink_cache_with_different_remote_size_still_reports_updatetest_symlinked_cache_with_stale_blob_still_reports_update(guards the existing hash path)test_symlinked_cache_with_current_blob_reports_no_update(guards the existing hash path)Fixes #7060