Skip to content

Studio: fix the permanent GGUF "update available" on no-symlink caches - #7113

Merged
danielhanchen merged 11 commits into
unslothai:mainfrom
gaurav0107:fix/7060-windows-no-symlink-gguf-update-badge
Jul 14, 2026
Merged

Studio: fix the permanent GGUF "update available" on no-symlink caches#7113
danielhanchen merged 11 commits into
unslothai:mainfrom
gaurav0107:fix/7060-windows-no-symlink-gguf-update-badge

Conversation

@gaurav0107

Copy link
Copy Markdown
Contributor

Summary

On Windows with Developer Mode off (the default), huggingface_hub has no symlink privilege, so hf_hub_download moves the downloaded blob into snapshots/ instead of symlinking it out of blobs/. blobs/ is left empty and scan_cache_dir reports blob_path = the snapshot file itself โ€” so Path(blob_path).name is the GGUF filename, not the file's etag.

_repo_gguf_blob_map recorded that filename as the file's local blob hash, so the remote_sha256 in local_set test in _variant_update_available_from_requirement could never match. Every cached GGUF reported update available forever, 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() treats Path(blob_path).name as a hash only when the file really lives in the cache's blobs/ dir (the layout invariant this module already relies on).
  • When there is no blob, _repo_gguf_blob_map records 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_requirement falls back to the remote ExpectedFile.size only when the cached file carries no blob hash. This is deliberately asymmetric: a size: 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/ -q from studio/backend (CI's invocation): 6369 passed, vs 6363 passed on a clean main with the identical command โ€” exactly the +6 new tests, with the pre-existing failure/error counts unchanged (82 failed / 70 errors both before and after; all from torch/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!
  • Confirmed the 4 new no-symlink tests fail on unpatched source and pass with the fix, so they genuinely pin the regression.
  • Reproduced the reporter's exact case in a scratch harness: a Qwen3-1.7B-Q2_K.gguf living directly in snapshots/ with blobs/ empty now yields identity size:778000000, matches the remote size, and reports update_available = False; bumping the remote size still reports True.

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_blob
  • test_repo_gguf_blob_map_skips_snapshot_file_with_unknown_size
  • test_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_update
  • test_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

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

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@gaurav0107
gaurav0107 force-pushed the fix/7060-windows-no-symlink-gguf-update-badge branch from ea96a51 to 97a6a9f Compare July 13, 2026 19:53
@gaurav0107
gaurav0107 marked this pull request as ready for review July 13, 2026 19:56

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

๐Ÿ’ก 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 ๐Ÿ‘ย / ๐Ÿ‘Ž.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@LeoBorcherding LeoBorcherding self-assigned this Jul 13, 2026
โ€ฆ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.
@danielhanchen

Copy link
Copy Markdown
Member

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 blobs/ is emptied (Windows without Developer Mode) the real hash is still on disk. This commit feeds that manifest sha256 into the local identity set and only falls back to the size: comparison when no real hash is present. Net effect:

  • Same file, same hash: no update (unchanged).
  • Manifest hash differs from remote: reported as an update even when the byte size is identical, which closes the equal-size requant blind spot the size-only fallback could not see.
  • No manifest (imported or legacy cache): behavior is exactly the size fallback as before, so nothing regresses.

The manifest read is gated behind downloaded, so it only runs for variants already on disk and stays off the hot path.

Added three regression tests in test_model_update_robustness.py covering all three cases. Existing update-detection and companion tests still pass.

danielhanchen added a commit to danielhanchen/unsloth-staging-2 that referenced this pull request Jul 14, 2026
โ€ฆ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.
@danielhanchen

Copy link
Copy Markdown
Member

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 _download_gguf_variant / _download_snapshot both call write_manifest(..., expected_files, ...) before snapshot_download), so it records what we intend to fetch, not what is verified on disk, and completion is checked by size only. Feeding those hashes into the local identity set could clear the update badge for an interrupted equal-size update that left the old bytes in place, and the all-size-identity gate I added also suppressed the size fallback whenever an older revision still had a real blob hash, which re-showed a false update on mixed hash and size caches.

The size:<bytes> fallback on its own already fixes #7060 cleanly, so I restored that and dropped the manifest overlay. The equal-size requant case stays a documented tradeoff.

โ€ฆ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.
@danielhanchen

Copy link
Copy Markdown
Member

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 reclaim_replaced_gguf_variant to prune stale same-variant revisions. On a no-symlink cache scan_cache_dir reports blob_path == the moved snapshot file, so its name is the filename, not an etag. Reclaim compared that filename against the current real hashes to keep, never matched, and unlinked the file it had just downloaded. Reproduced end to end against a real scan_cache_dir: removed_snapshots=1 and the freshly downloaded file was gone.

The fix only extracts a deletable hash when the blob path is a real cache blob directly under the repo blobs directory, and keeps anything it cannot positively identify, so a no-symlink current file is never removed. Stale no-symlink old revisions leak rather than risk deleting the current file, which matches the conservative size fallback used here. Added a regression test alongside the existing symlinked-cache reclaim test.

danielhanchen added a commit to danielhanchen/unsloth-staging-2 that referenced this pull request Jul 14, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Jul 14, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Jul 14, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Jul 14, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Jul 14, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Jul 14, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Jul 14, 2026
@danielhanchen

Copy link
Copy Markdown
Member

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

๐Ÿ’ก 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".

Comment thread studio/backend/hub/services/models/cache_inventory.py Outdated
danielhanchen and others added 2 commits July 14, 2026 11:59
โ€ฆ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.
danielhanchen added a commit to danielhanchen/unsloth-staging-2 that referenced this pull request Jul 14, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Jul 14, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Jul 14, 2026
@danielhanchen

Copy link
Copy Markdown
Member

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Chef's kiss.

Reviewed commit: 64cdc21af8

โ„น๏ธ 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".

Imagineer99 and others added 2 commits July 14, 2026 14:05
โ€ฆ7060)

Post-review comment pass: shorten the internal blob-identity and size-fallback docstrings. Comments only, no behavior change.
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Jul 14, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Jul 14, 2026
@danielhanchen

Copy link
Copy Markdown
Member

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Swish!

Reviewed commit: db20843bb5

โ„น๏ธ 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".

@danielhanchen
danielhanchen merged commit eb31d1e into unslothai:main Jul 14, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

GGUF quants permanently show "update available" on Windows (no-symlink cache)

4 participants