Skip to content

Studio: bring back the Resume button for past training runs - #11301

Merged
mahiatlinux merged 2 commits into
unslothai:mainfrom
NilayYadav:fix-resume-attest-shared-hub-blobs
Sep 20, 2026
Merged

mahiatlinux merged 2 commits into
unslothai:mainfrom
NilayYadav:fix-resume-attest-shared-hub-blobs

Conversation

@NilayYadav

@NilayYadav NilayYadav commented Sep 18, 2026 •

Copy link
Copy Markdown
Collaborator

On a fresh install, no past training run could be resumed. History showed no Resume button, and the reason given was "The model revision used by this run was not attested."

The installer now pulls huggingface_hub 1.32. That version keeps large model files in one shared folder for the whole download cache and links each model's copy to it. Studio's check only trusted files inside the model's own folder, so it rejected every weight file and marked the run as not safe to resume.

The check now also trusts the shared folder of the same download cache. Files that point anywhere else are still rejected. The same change is made for dataset files, which are checked the same way.

The size of a cached model was measured with the same narrow check, so it came back unknown for those models. That made Studio skip the step that picks LoRA or QLoRA for you based on free memory. It is now measured the same way as the checks above.


Before and after

Two isolated installs, one at the merge base 768d644 and one at this PR's head 1d941ba, both pointed at the same huggingface_hub 1.32 cache whose weights live in the shared blob store. Each Studio was given the same past run: stopped at step 10 of 60, a valid checkpoint on disk, and a provenance marker recording the model revision as already attested, which is the shape a run trained before the hub upgrade carries.

Resume button, before and after

Read from each of the two servers photographed:

BEFORE 768d644 AFTER 1d941ba
can_resume false true
resume_blocked_reason "The exact model snapshot for this run is no longer available." none
Resume training button absent present

@NilayYadav
NilayYadav force-pushed the fix-resume-attest-shared-hub-blobs branch from 34ced6a to 1d941ba Compare September 19, 2026 22:24
@NilayYadav

Copy link
Copy Markdown
Collaborator Author

@codex review

1 similar comment
@NilayYadav

Copy link
Copy Markdown
Collaborator Author

@codex review

@danielhanchen

Copy link
Copy Markdown
Member

Confirmed the check in studio/backend/core/training/provenance.py only trusts the repo's own blobs dir, which is what leaves past runs unattested, and the same narrow rule is what made cached model size come back unknown. Could you note which huggingface_hub version you reproduced the shared blob layout on, so I can repeat it before reviewing?

@danielhanchen

Copy link
Copy Markdown
Member

Reproduced on huggingface_hub 1.32.0, which is the release that adds the cache-wide shared blob store (huggingface_hub/utils/_shared_blobs.py: entries at <cache_root>/blobs/<2hex>/<64hex>, store marked by .huggingface-shared-blobs containing 1). A fresh snapshot_download of hf-internal-testing/tiny-random-LlamaForCausalLM produces exactly the two-hop chain you describe:

snapshots/<sha>/model.safetensors -> ../../blobs/<etag> -> ../blobs/f5/f54827240aac...

Running the real code against that cache, merge base 768d644 vs head 1d941ba:

check 768d644 1d941ba
model.safetensors (shared store) accepted by _resolved_model_snapshot_file False True
tokenizer.model (shared store) accepted False True
the 8 non-Xet files (repo-local blobs) accepted True True
_snapshot_has_model_weights False True
_get_snapshot_model_size_bytes None 4131280

So the diagnosis is right and the fix works. Applying the three new positive tests to the base tree, they fail there and pass on head, while the five negative cases pass on both, so the differential is sound. tests/test_training_provenance.py, test_training_cached_start.py, test_resume_reason_matches_cause.py, test_resume_blocker_reason.py: 312 passed on base, 316 on head, 320 with the new size file. No regressions.

Three things I would tighten before merge.

1. <cache>/blobs is trusted as a path prefix, not as a hub store. The new branch accepts any file at any depth under repo_dir.parent / "blobs". Measured on head:

snapshot entry resolves to accepted
<cache>/blobs/87/<64hex> (well formed) True
<cache>/blobs/anything.safetensors (no marker, wrong shape) True
<cache>/blobs/aa/bb/cc/payload True
<cache>/models--org--other/blobs/weights False
outside the cache False

The two controls still hold, so this is not an escape, but the accepted set is wider than what hub itself will ever create. _shared_blobs.py exposes SHARED_BLOBS_DIR_NAME, is_shared_blobs_dir (marker plus version) and shared_blob_target (validates <2hex>/<64hex> and that the prefix matches the hash). Gating the new branch on is_shared_blobs_dir(repo_dir.parent / SHARED_BLOBS_DIR_NAME) turns "anything under blobs" into "a hub-owned store". Those names are private, but hub/utils/inventory_scan.py:155-164 already establishes the house pattern for that: import behind try/except with a literal fallback.

2. _get_snapshot_model_size_bytes does not require a hub cache root. It checks only snapshots_dir.name == "snapshots", so its own contract now trusts <anything>/blobs. With a snapshot at temp/somewhere/foo/snapshots/rev and the payload at temp/somewhere/blobs/payload, base returns None and head returns 4096. The one production caller goes through latest_snapshot_from_cache_path, so this is defence in depth rather than a live hole, and the blast radius is a size total rather than attestation. The provenance and dataset helpers are covered by validated_repo_cache_path, which checks both the models--* name and that the parent matches an hf_cache_roots() entry.

3. The three call sites disagree about resolving the blob roots. routes/models.py resolves both with .resolve(strict = True); provenance.py and dataset_cache.py compare a fully resolved target against an unresolved repo_dir / "blobs" and repo_dir.parent / "blobs". Since repo_dir is itself resolved this fails closed, so it is a usability bug rather than a hole, but the two now disagree. With <cache>/blobs symlinked to another volume, which is a plausible thing to do with the store that holds all the weights:

head
_get_snapshot_model_size_bytes 4096
_resolved_model_snapshot_file rejected

The size shows up, the Resume button still does not, and the reason given is still "not attested". Resolving the blob roots in provenance.py and dataset_cache.py the way routes/models.py already does removes the divergence without loosening anything. Worth a test either way, since nothing currently pins it.

Separately, the same assumption is broken in four other places that this PR does not touch. All four behave identically on base and head, so they are not regressions from this change, but they are the same root cause and the fix is incomplete without them. Measured against the same real 1.32 cache, where the snapshot payload is 14,882,707 bytes:

  1. core/inference/video.py:3609 _cache_bytes counts only not os.path.islink(path). That was correct when the repo blob was a real file; now it is a symlink too, so the walk reports 1,854,461 of 14,882,707 bytes. A video model download will sit near zero for the whole pull. This is the direct twin of the sizing bug fixed here.
  2. hub/utils/hf_cache_state.py:377 cached_repo_ref_for_path returns None for a Xet-backed file, because the resolved path lands in the shared store and has no models-- ancestor. Its own docstring says None is read as "no check needed" and is fail-OPEN, and routes/training.py:723 then hits continue and skips the access check entirely. Measured: None for model.safetensors, correct ref for config.json and for the snapshot directory. This is the one I would fix first.
  3. hub/services/models/account_access.py:740 model_visible compares _cached_repo(resolved) against _cached_repo(path) on the premise that "snapshots point at their own repo's blobs". Measured with the grant present: False for a Xet weight, True for config.json and for the snapshot directory, so require_model_access 404s a model the account is actually granted.
  4. hub/services/datasets/cache_inventory.py:72 _directory_stats skips symlinks, giving 1,852,768 bytes for the repo's blobs directory, and the _directory_stats(entry) fallback does not recover it either. Dataset sizes undercount by whatever is Xet backed.

Two more I only read rather than ran, so treat them as leads: hub/utils/inventory_scan.py:282 fully resolves where hub does a single readlink, which then makes cache_inventory._is_real_cache_blob reject the blob and degrade GGUF identity to size based; and hub/services/models/deletion.py:490 unlinks the per-repo symlink while adding the followed size to deleted_bytes, which over-reports freed space and leaves the shared payload behind.

Happy to take the four follow-ups in a separate PR so this one stays scoped to resume. For this PR, points 1 to 3 above are what I would like to see.

@mahiatlinux

mahiatlinux commented Sep 20, 2026 •

Copy link
Copy Markdown
Collaborator

Reproduced with huggingface_hub 1.32.0 on Linux x86_64. Its live downloads placed the tiny Llama model weights and LibriSpeech dummy parquet data in the cache-wide blob store, as described in the 1.32.0 release.

Compared base 768d644036a948441ba80b626c9e747c1fecfbae, unmodified head 1d941ba6d172071d7d6cd6f208e3b434db8450b2, and prospective merge a86aa46dd39f2db8263d347ea5342aa270c48d48.

Check Base Head / merge
Shared model weights Rejected; size unknown Accepted; 4,131,280 bytes
Real dataset load, datasets 4.3.0, 73 parquet rows Source unattested Source attested
Persisted provenance and history API for the checkpoint fixture can_resume=false can_resume=true
Regular files, legacy repo blobs, mixed layouts Existing behavior retained Passed
External, other-repo, prefix-collision, broken, looping and escaping links Rejected Rejected
Focused regression suites, Python 3.13.14 872 passed 880 passed on merge; corresponding head suites passed
Python 3.10.20 / Hub 1.23.0; Python 3.14.4 / Hub 1.32.0 197 passed in each environment

The independent shared-blob check fails on base specifically because the shared files are rejected. Hub 1.32.0 reads the old cache after upgrade; Hub 1.23.0 reads both old and new shared caches offline on rollback. The pinned formatter leaves all five changed files byte-identical, lint and diff checks pass, and both frontend builds pass. No source repairs were necessary.

Scope clarification: this prevents new runs from being recorded as unattested and accepts valid shared-cache pins. It does not repair previously persisted incomplete provenance. The second card deliberately retains that older marker and remains blocked on both revisions.

Manually inspected screenshots from Chrome 153.0.8010.52, Edge 153.0.4234.48, Firefox 155.0 and Chromium 153.0.8010.12, at 1440×1000 and 390×844. Separate builds, application homes and browser contexts; refresh and focus checks passed. These use deterministic stopped-checkpoint fixtures and the real provenance, filesystem, database and history API. Only the browser's hardware-navigation gate is overridden to expose Train in the dependency-light install; this is not a GPU training run.

Before and after, Chrome

Full logs, exact commands, dependency versions, scripts and all browser comparisons. Review mirror.

Mirror review completed without findings on a86aa46dd39f2db8263d347ea5342aa270c48d48. The mirror and source patch hashes match; no source commits were added.

@mahiatlinux
mahiatlinux self-requested a review September 20, 2026 04:15

@mahiatlinux mahiatlinux left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM! Ready to merge, all manually checked.

@mahiatlinux
mahiatlinux merged commit 9f5c792 into unslothai:main Sep 20, 2026
46 checks passed
danielhanchen added a commit to danielhanchen/unsloth-staging-2 that referenced this pull request Sep 20, 2026
@danielhanchen

Copy link
Copy Markdown
Member

Ran the full simulation on this. Summary first, then the evidence.

issue: PARTIAL, the "past runs" half is not fixed | PR as submitted: DEFECTIVE, 3 found | merge: YES AFTER

The headline claim does not hold for runs recorded while the bug was live

model_status is written exactly once, by build_worker_provenance_event, from the training worker at core/training/worker.py:4604. It is the only writer, and nothing re-attests a stored run. exact_resume_resource_requirements raises on the stored value at provenance.py:923, before validate_exact_model_pin runs, so the widened predicate is never reached for a run whose marker already says incomplete.

Two stored-run shapes against the real 1.32 cache, 768d644 vs 1d941ba:

stored marker base head
model_status="incomplete" (run trained under 1.32, worker attestation already failed) can_resume=False, "The model revision used by this run was not attested." same, unchanged
model_status="attested" (run trained under an older hub, only the on-disk recheck fails) can_resume=False, "The exact model snapshot for this run is no longer available." can_resume=True

The second row is a real fix. The first is not, and the PR description quotes that exact message as the symptom. Worth either narrowing the title to the runs that do recover, or adding a re-attestation path for stored incomplete markers, which is a bigger change and your call rather than mine.

Three defects in the changed predicates

Measured on head, all with the real controls still holding (sibling repo blobs and outside-the-cache stay rejected in every case):

case base head
shared store, marked, well formed reject accept (correct)
<cache>/blobs/anything.safetensors, no marker, no <2hex>/<64hex> shape reject accept
<cache>/blobs/aa/bb/cc/payload reject accept
_get_snapshot_model_size_bytes on a snapshot in no hub cache at all None 4096, from <anything>/blobs
<cache>/blobs is a symlink: size vs attestation None / reject 4096 / still reject
  1. The branch trusts a path prefix, not a store. hub marks the store it creates and checks that marker before using it (is_shared_blobs_dir in huggingface_hub/utils/_shared_blobs.py); the accepted set here is wider than anything hub will ever create.
  2. _get_snapshot_model_size_bytes checks only snapshots_dir.name == "snapshots", so after the widening its own contract trusts <anything>/blobs. Its one production caller goes through latest_snapshot_from_cache_path, so this is defence in depth, but it is a size total read from a directory that was never established to be a cache.
  3. The three call sites disagree about resolution: routes/models.py resolves both blob roots, the two attestation helpers compare a resolved target against a literal path. With the store relocated to another volume the model reports a size while the run still says the revision was not attested.

Patch below. It routes all three through one trusted_blob_roots(repo_dir) in hub_cache_state, which resolves both roots and asks hub's own is_shared_blobs_dir, with a literal marker check as the fallback for a hub too old to export it (such a hub never creates the store, so the fallback answers False and the pre-1.32 behaviour is unchanged). On (3) it settles the disagreement by matching upstream rather than by loosening: is_shared_blobs_dir lstats the leaf, so hub itself refuses a symlinked blobs and never publishes into one. Verified: False for a symlink to a marked dir, True for the marked dir itself.

I could not push this to the branch, maintainerCanModify is false on the PR, so it is here to apply or discard.

patch
diff --git a/studio/backend/core/training/provenance.py b/studio/backend/core/training/provenance.py
index 6781c3795..99ef7d1ac 100644
--- a/studio/backend/core/training/provenance.py
+++ b/studio/backend/core/training/provenance.py
@@ -195,7 +195,7 @@ def _snapshot_declares_quantization(snapshot: Path) -> bool:
 
 
 def _resolved_model_snapshot_file(snapshot: Path, path: Path) -> Optional[Path]:
-    from hub.utils.hf_cache_state import same_existing_path
+    from hub.utils.hf_cache_state import same_existing_path, trusted_blob_roots
 
     try:
         snapshot = snapshot.resolve(strict = True)
@@ -208,8 +208,7 @@ def _resolved_model_snapshot_file(snapshot: Path, path: Path) -> Optional[Path]:
         return None
     if not resolved.is_file() or not (
         resolved.is_relative_to(snapshot)
-        or resolved.is_relative_to(repo_dir / "blobs")
-        or resolved.is_relative_to(repo_dir.parent / "blobs")
+        or any(resolved.is_relative_to(root) for root in trusted_blob_roots(repo_dir))
     ):
         return None
     try:
diff --git a/studio/backend/hub/utils/dataset_cache.py b/studio/backend/hub/utils/dataset_cache.py
index 03bb77559..419f63055 100644
--- a/studio/backend/hub/utils/dataset_cache.py
+++ b/studio/backend/hub/utils/dataset_cache.py
@@ -20,6 +20,7 @@ from hub.utils.hf_cache_state import (
     iter_repo_cache_dirs,
     ref_snapshot_dir,
     same_existing_path,
+    trusted_blob_roots,
     validated_repo_cache_path,
 )
 from utils.paths.path_utils import drop_appledouble_metadata, is_appledouble_metadata
@@ -279,8 +280,7 @@ def resolved_dataset_snapshot_file(snapshot: str | Path, source_path: str) -> Op
         return None
     if not resolved.is_file() or not (
         resolved.is_relative_to(snapshot_path)
-        or resolved.is_relative_to(repo_dir / "blobs")
-        or resolved.is_relative_to(repo_dir.parent / "blobs")
+        or any(resolved.is_relative_to(root) for root in trusted_blob_roots(repo_dir))
     ):
         return None
     try:
diff --git a/studio/backend/hub/utils/hf_cache_state.py b/studio/backend/hub/utils/hf_cache_state.py
index da97214de..9f1607571 100644
--- a/studio/backend/hub/utils/hf_cache_state.py
+++ b/studio/backend/hub/utils/hf_cache_state.py
@@ -142,6 +142,59 @@ def same_existing_path(first: Path, second: Path) -> bool:
         return False
 
 
+SHARED_BLOBS_MARKER_NAME = ".huggingface-shared-blobs"
+
+
+def _resolved_existing_dir(path: Path) -> Optional[Path]:
+    try:
+        return path.resolve(strict = True) if path.is_dir() else None
+    except (OSError, RuntimeError, ValueError):
+        return None
+
+
+def _is_hub_shared_blobs_dir(path: Path) -> bool:
+    """Is this huggingface_hub's own cache-wide shared blob store, not just a folder called ``blobs``?
+
+    Read from upstream rather than reimplemented: hub validates an ownership marker AND its
+    layout version, and a later hub may bump that version, so asking the installed hub keeps
+    us in step with the cache it actually writes. The literal marker check is the fallback for
+    a hub too old to export the helper -- such a hub also never creates the store, so the
+    fallback answers False and the caller behaves exactly as it did before 1.32.
+    """
+    try:
+        from huggingface_hub.utils._shared_blobs import is_shared_blobs_dir
+        return bool(is_shared_blobs_dir(path))
+    except Exception:
+        pass
+    try:
+        return path.is_dir() and (path / SHARED_BLOBS_MARKER_NAME).is_file()
+    except (OSError, ValueError):
+        return False
+
+
+def trusted_blob_roots(repo_dir: Path) -> tuple[Path, ...]:
+    """Resolved directories a file inside ``repo_dir``'s snapshot may legitimately resolve into.
+
+    The repo's own ``blobs``, plus huggingface_hub 1.32's cache-wide shared Xet store at
+    ``<cache_root>/blobs``: 1.32 turned each repo's ``blobs/<etag>`` into a symlink into that
+    store, so a weight file resolves outside its repo folder without leaving the cache.
+
+    Both roots are RESOLVED, because callers compare them against a fully resolved candidate.
+    Comparing against a literal path silently rejects the very files it is meant to admit when
+    a ``blobs`` leaf is a symlink, which is how a big shared store ends up on another volume.
+    """
+    roots: list[Path] = []
+    own = _resolved_existing_dir(repo_dir / "blobs")
+    if own is not None:
+        roots.append(own)
+    shared = repo_dir.parent / "blobs"
+    if _is_hub_shared_blobs_dir(shared):
+        resolved_shared = _resolved_existing_dir(shared)
+        if resolved_shared is not None and resolved_shared not in roots:
+            roots.append(resolved_shared)
+    return tuple(roots)
+
+
 def hf_cache_root(
     *,
     create: bool = False,
diff --git a/studio/backend/routes/models.py b/studio/backend/routes/models.py
index 37f86499b..60fe0d263 100644
--- a/studio/backend/routes/models.py
+++ b/studio/backend/routes/models.py
@@ -2081,14 +2081,12 @@ def _get_snapshot_model_size_bytes(snapshot_path: str) -> Optional[int]:
         repo_dir = snapshots_dir.parent.resolve(strict = True)
         if not snapshot.is_dir() or snapshots_dir.name != "snapshots" or not repo_dir.is_dir():
             return None
-        blobs_dir = repo_dir / "blobs"
-        resolved_blobs_dir = blobs_dir.resolve(strict = True) if blobs_dir.is_dir() else None
-        # hub 1.x keeps one content-addressed blob store per cache root and links each repo's
-        # blobs into it, so a weight file resolves outside the repo without leaving the cache.
-        shared_blobs_dir = repo_dir.parent / "blobs"
-        resolved_shared_blobs_dir = (
-            shared_blobs_dir.resolve(strict = True) if shared_blobs_dir.is_dir() else None
-        )
+        # The repo's own blobs, plus hub 1.32's cache-wide shared store: it links each repo's
+        # blobs into one content-addressed folder per cache root, so a weight file resolves
+        # outside the repo without leaving the cache. Same roots the attestation check trusts.
+        from hub.utils.hf_cache_state import trusted_blob_roots
+
+        blob_roots = trusted_blob_roots(repo_dir)
     except (OSError, RuntimeError, ValueError):
         return None
 
@@ -2114,8 +2112,7 @@ def _get_snapshot_model_size_bytes(snapshot_path: str) -> Optional[int]:
                     if not candidate.is_file():
                         continue
                     if not candidate.is_relative_to(snapshot) and not any(
-                        blob_root is not None and candidate.is_relative_to(blob_root)
-                        for blob_root in (resolved_blobs_dir, resolved_shared_blobs_dir)
+                        candidate.is_relative_to(blob_root) for blob_root in blob_roots
                     ):
                         continue
                     total += candidate.stat().st_size
diff --git a/studio/backend/tests/test_model_size_shared_blob_store.py b/studio/backend/tests/test_model_size_shared_blob_store.py
index 734966eb6..b5d507e9b 100644
--- a/studio/backend/tests/test_model_size_shared_blob_store.py
+++ b/studio/backend/tests/test_model_size_shared_blob_store.py
@@ -39,8 +39,16 @@ def _link_through_repo_blob(snapshot: Path, name: str, target: Path) -> None:
     (snapshot / name).symlink_to(os.path.relpath(repo_blob, snapshot))
 
 
+def _mark_shared_store(cache_root: Path) -> None:
+    """The ownership marker hub writes at the root of a store it created."""
+    store = cache_root / "blobs"
+    store.mkdir(parents = True, exist_ok = True)
+    (store / ".huggingface-shared-blobs").write_text("1\n")
+
+
 def test_model_size_counts_weights_in_the_hub_shared_blob_store(tmp_path):
     snapshot = _snapshot(tmp_path, "org/model")
+    _mark_shared_store(tmp_path)
     sha = "8788269b" * 8
     shared = tmp_path / "blobs" / sha[:2] / sha
     shared.parent.mkdir(parents = True)
@@ -50,6 +58,38 @@ def test_model_size_counts_weights_in_the_hub_shared_blob_store(tmp_path):
     assert models_route._get_snapshot_model_size_bytes(str(snapshot)) == len(WEIGHTS)
 
 
+def test_model_size_ignores_a_weight_under_an_unmarked_blobs_dir(tmp_path):
+    """Sizing trusts the same roots attestation does, so a bare ``blobs`` folder is not one."""
+    snapshot = _snapshot(tmp_path, "org/model")
+    sha = "8788269b" * 8
+    unmarked = tmp_path / "blobs" / sha[:2] / sha
+    unmarked.parent.mkdir(parents = True)
+    unmarked.write_bytes(WEIGHTS)
+    _link_through_repo_blob(snapshot, "model.safetensors", unmarked)
+
+    assert models_route._get_snapshot_model_size_bytes(str(snapshot)) is None
+
+
+def test_model_size_ignores_a_weight_in_a_symlinked_shared_store(tmp_path):
+    """Sizing and attestation answer the same question about a symlinked ``blobs`` leaf.
+
+    Before, sizing accepted it and attestation did not, so the model reported a size while its
+    run stayed unresumable and said the revision was not attested.
+    """
+    snapshot = _snapshot(tmp_path, "org/model")
+    elsewhere = tmp_path / "another-volume"
+    elsewhere.mkdir()
+    (tmp_path / "blobs").symlink_to(elsewhere)
+    (elsewhere / ".huggingface-shared-blobs").write_text("1\n")
+    sha = "8788269b" * 8
+    shared = elsewhere / sha[:2] / sha
+    shared.parent.mkdir(parents = True)
+    shared.write_bytes(WEIGHTS)
+    _link_through_repo_blob(snapshot, "model.safetensors", shared)
+
+    assert models_route._get_snapshot_model_size_bytes(str(snapshot)) is None
+
+
 def test_model_size_still_counts_a_weight_in_the_repos_own_blobs(tmp_path):
     snapshot = _snapshot(tmp_path, "org/model")
     repo_blob = snapshot.parent.parent / "blobs" / ("a1b2c3d4" * 8)
diff --git a/studio/backend/tests/test_training_provenance.py b/studio/backend/tests/test_training_provenance.py
index 776e586dc..803239249 100644
--- a/studio/backend/tests/test_training_provenance.py
+++ b/studio/backend/tests/test_training_provenance.py
@@ -635,8 +635,24 @@ def test_exact_model_snapshot_accepts_own_blob_symlink(tmp_path):
     assert exact_model_snapshot_path(str(snapshot), "org/model") == str(snapshot.resolve())
 
 
-def _shared_store_blob_symlink(repo: Path, link: Path, payload: bytes) -> Path:
+def _mark_shared_store(cache_root: Path) -> Path:
+    """Write the ownership marker hub puts at the root of a store it created.
+
+    Without it the directory is just a folder named ``blobs``, and a fixture that omits it
+    proves acceptance of any such folder rather than of hub's store.
+    """
+    store = cache_root / "blobs"
+    store.mkdir(parents = True, exist_ok = True)
+    (store / ".huggingface-shared-blobs").write_text("1\n")
+    return store
+
+
+def _shared_store_blob_symlink(
+    repo: Path, link: Path, payload: bytes, *, marked: bool = True
+) -> Path:
     sha = "c791637d" * 8
+    if marked:
+        _mark_shared_store(repo.parent)
     shared = repo.parent / "blobs" / sha[:2] / sha
     shared.parent.mkdir(parents = True, exist_ok = True)
     shared.write_bytes(payload)
@@ -655,6 +671,37 @@ def test_exact_model_snapshot_accepts_hub_shared_blob_store(tmp_path):
     assert exact_model_snapshot_path(str(snapshot), "org/model") == str(snapshot.resolve())
 
 
+def test_exact_model_snapshot_rejects_unmarked_blobs_dir(tmp_path):
+    """A folder called ``blobs`` beside the repo is not hub's shared store.
+
+    Only hub creates that store, and it marks what it created. Trusting the name alone would
+    make every readable file under it attestable, which is a wider set than the cache ever holds.
+    """
+    snapshot = _model_snapshot(tmp_path, "org/model", "unmarked", weights = False)
+    _shared_store_blob_symlink(
+        snapshot.parent.parent, snapshot / "model.safetensors", b"weights", marked = False
+    )
+
+    assert exact_model_snapshot_path(str(snapshot), "org/model") is None
+
+
+def test_exact_model_snapshot_rejects_symlinked_shared_store(tmp_path):
+    """A ``blobs`` leaf that is itself a symlink is not a store hub owns.
+
+    ``is_shared_blobs_dir`` lstats the leaf, so hub never adopts one and never publishes into
+    it. Sizing asks the same question, so the two cannot disagree and leave a model whose size
+    displays but whose run will not resume.
+    """
+    snapshot = _model_snapshot(tmp_path, "org/model", "relocated", weights = False)
+    elsewhere = tmp_path / "another-volume"
+    elsewhere.mkdir()
+    (tmp_path / "blobs").symlink_to(elsewhere)
+    (elsewhere / ".huggingface-shared-blobs").write_text("1\n")
+    _shared_store_blob_symlink(snapshot.parent.parent, snapshot / "model.safetensors", b"weights")
+
+    assert exact_model_snapshot_path(str(snapshot), "org/model") is None
+
+
 @pytest.mark.parametrize("target", ["outside-cache", "other-repo-blobs"])
 def test_exact_model_snapshot_rejects_blob_symlink_escaping_repo(
     tmp_path, tmp_path_factory, target
@@ -891,6 +938,31 @@ def test_loaded_hub_dataset_accepts_hub_shared_blob_store(tmp_path):
     assert exact_dataset_snapshot_path(str(snapshot), "org/dataset") == str(snapshot.resolve())
 
 
+@pytest.mark.parametrize("target", ["outside-cache", "other-repo-blobs", "unmarked-blobs"])
+def test_loaded_hub_dataset_rejects_blob_symlink_escaping_repo(
+    tmp_path, tmp_path_factory, target
+):
+    """The dataset predicate was widened exactly as the model one was, so it is bounded the same."""
+    repo = tmp_path / "datasets--org--dataset"
+    snapshot = repo / "snapshots" / "dataset-commit"
+    snapshot.mkdir(parents = True)
+    if target == "unmarked-blobs":
+        _shared_store_blob_symlink(repo, snapshot / "train.parquet", b"dataset", marked = False)
+    else:
+        if target == "outside-cache":
+            escaped = tmp_path_factory.mktemp("elsewhere") / "blobs" / "c7" / "train.parquet"
+        else:
+            escaped = tmp_path / "datasets--org--other" / "blobs" / "train.parquet"
+        escaped.parent.mkdir(parents = True)
+        escaped.write_bytes(b"dataset")
+        repo_blob = repo / "blobs" / "dataset-blob"
+        repo_blob.parent.mkdir(parents = True)
+        repo_blob.symlink_to(escaped)
+        (snapshot / "train.parquet").symlink_to(os.path.relpath(repo_blob, snapshot))
+
+    assert exact_dataset_snapshot_path(str(snapshot), "org/dataset") is None
+
+
 def test_loaded_hub_dataset_rejects_local_source_symlink_outside_repo(tmp_path):
     snapshot = _shared_setup_1(tmp_path)
     external = tmp_path / "external.parquet"

Evidence behind the rest

  • Executed-line proof: instrumented so the new clause raises when it is the deciding one, on a real 1.32 cache it raises on blobs/f5/f54827240aac.... Reverting only the three functional hunks and keeping the tests gives 3 failed, 98 passed, so the tests are not testing their own mocks.
  • Widening only: 13 target locations x 3 blobs topologies x 2 entry kinds x 2 readability, all three predicates, 468 cells. 396 identical, 28 new-accept, 0 new-reject, 18 not applicable. Every moving cell has a shared-store target.
  • hub corners, real caches at each: 0.34.0, 1.23.0 and 1.31.0 all keep repo-local blobs and give identical results on both arms; 1.32.0 is where the store appears (_shared_blobs is absent in 1.31.0, present in 1.32.0). That answers the version question from the thread.
  • Cost on an old-layout cache, the user who never hits this, 200 calls, median [p5, p95] us: _snapshot_has_model_weights 1528.7 [1392.3, 1755.9] to 1601.2 [1441.8, 1850.4]; _get_snapshot_model_size_bytes 197.7 [194.4, 260.8] to 236.1 [207.1, 281.1]. Bands overlap. Nothing added to an inference path; the only production caller of the size helper is get_model_config, already off the event loop.
  • Cross-platform, counts from the run logs, not ticks: ubuntu-latest, macos-15 (Apple Silicon) and windows-latest all 101 passed 1 skipped on the PR as submitted; with the patch, 108 passed 1 skipped on ubuntu-latest and macos-15. windows-11-arm failed at Failed building wheel for pyarrow on both arms, so the tests never ran there: void, not a result.
  • The config schema is untouched, so an older build still parses what the new one writes. Rolling back after a shared-store attestation leaves the run unresumable on the old build, which is the same layout incompatibility this PR fixes rather than a migration problem.

One note on the fixtures: they build a well-shaped store without the marker, so as written they pin acceptance of the directory name rather than of hub's store. The patch writes the marker and adds the cases nothing covered, an unmarked blobs, a symlinked leaf, and the dataset-side rejections, which only existed for models.

The four adjacent sites with this same root cause that this PR does not touch are in my earlier comment; none of them regress here, and the cached_repo_ref_for_path one is the reason I would take them soon rather than eventually.

@danielhanchen

Copy link
Copy Markdown
Member

Windows leg on the patched code finished: 108 passed, 1 skipped on windows-latest, matching ubuntu-latest and macos-15. So all three runnable platforms agree on the patch; windows-11-arm is still void on both arms for the pyarrow wheel.

@danielhanchen

Copy link
Copy Markdown
Member

Review record for #11301, carried over from the mirror it was reviewed on: danielhanchen/unsloth-staging-review#20.

Findings are quoted as posted and attributed to the account that posted them. The reactions shown are the triage recorded on the mirror; none were re-applied here. Commits are named as the mirror's, with this PR's equivalent where one was found.

Round 1 — reviewed mirror 17e4029f2 (no equivalent commit found on this PR)

chatgpt-codex-connector[bot] (on the mirror)

🛡️ Codex Security Review

Security review completed. No security issues were found in this pull request.

Reviewed commit: 17e4029f24

View security finding report

Only the user who started this review can view the report in Codex.

No triage reaction was recorded on the mirror

Round 2 — reviewed mirror ca11dd424 (no equivalent commit found on this PR)

chatgpt-codex-connector[bot] (on the mirror)

Codex Review: Didn't find any major issues. Keep them coming!

Reviewed commit: ca11dd424b

No triage reaction was recorded on the mirror

Verdict

The round converged at mirror ca11dd424 (no equivalent commit found on this PR).

This is the review as it stood at that commit. Anything pushed to this PR afterwards was not part of it.

@danielhanchen

Copy link
Copy Markdown
Member

Converged. The review ran on a mirror because Codex only answers on repos this account controls, and the round-by-round record is in the comment above.

Round 2 came back clean ("Reviewed commit ca11dd424", no issues), so this is the end of the loop rather than a pause in it.

Round 1 raised two items, both against my own patch rather than yours, both correct and both fixed:

  1. A symlinked blobs directory became a trust root. My first patch resolved the leaf before asking what it was, and is_dir() follows symlinks while resolve() returns the target (pathlib: "This method normally follows symlinks", "Make the path absolute, resolving any symlinks"). Reproduced with the repo's own blobs pointing at a directory outside the cache: merge base 768d644 returns None, your head 1d941ba returns None, my patch returned the external path. lstat the leaf first, which is also what huggingface_hub's own _is_directory does in utils/_shared_blobs.py. A real directory resolves to itself, so the repo-own root behaves exactly as the merge base did.
  2. The fallback trusted a marker by filename. It only runs on a hub with no huggingface_hub.utils._shared_blobs, and such a hub never creates the store, so every marker it can see is hand made. It now requires what upstream requires: a real directory holding a regular marker file with a layout version line. Not pinned to the literal 1\n, since a later hub may bump SHARED_BLOBS_LAYOUT_VERSION and the installed helper decides that when it is present.

Re-proven after both fixes, same seeded 1.32 cache as before: the real cached model still attests and still sizes (HAS_WEIGHTS True, SIZE_BYTES 4131280); a marked store is accepted; an unmarked blobs, a file at any depth under one, a snapshot in no hub cache, and a symlinked leaf are all rejected; the symlinked repo-own blobs is rejected again. Tests 110 passed 1 skipped in the two provenance files, 411 in the dataset-cache and hf-cache-state suites, ruff clean.

This is the second and final patch. maintainerCanModify is false on this PR so I still cannot push; applying both this and the earlier one gives you the reviewed, converged state.

patch 2 of 2
diff --git a/studio/backend/hub/utils/hf_cache_state.py b/studio/backend/hub/utils/hf_cache_state.py
index 9f1607571..7ad778787 100644
--- a/studio/backend/hub/utils/hf_cache_state.py
+++ b/studio/backend/hub/utils/hf_cache_state.py
@@ -143,11 +143,22 @@ def same_existing_path(first: Path, second: Path) -> bool:
 
 
 SHARED_BLOBS_MARKER_NAME = ".huggingface-shared-blobs"
+_SHARED_BLOBS_LAYOUT_RE = re.compile(r"\A[0-9]+\n\Z")
 
 
-def _resolved_existing_dir(path: Path) -> Optional[Path]:
+def _resolved_real_dir(path: Path) -> Optional[Path]:
+    """Resolve ``path``, but only if the leaf itself is a real directory.
+
+    ``is_dir()`` follows symlinks and ``resolve()`` then returns wherever the link points, so
+    resolving first and asking questions afterwards would make a ``blobs`` symlink into a
+    trust root anchored outside the cache. Callers compare a fully resolved candidate against
+    what this returns, so that would admit externally mutable bytes as an exact snapshot.
+    ``lstat`` is the difference: it reports the link, not its target.
+    """
     try:
-        return path.resolve(strict = True) if path.is_dir() else None
+        if not stat_module.S_ISDIR(path.lstat().st_mode):
+            return None
+        return path.resolve(strict = True)
     except (OSError, RuntimeError, ValueError):
         return None
 
@@ -157,17 +168,24 @@ def _is_hub_shared_blobs_dir(path: Path) -> bool:
 
     Read from upstream rather than reimplemented: hub validates an ownership marker AND its
     layout version, and a later hub may bump that version, so asking the installed hub keeps
-    us in step with the cache it actually writes. The literal marker check is the fallback for
-    a hub too old to export the helper -- such a hub also never creates the store, so the
-    fallback answers False and the caller behaves exactly as it did before 1.32.
+    us in step with the cache it actually writes. The fallback is for a hub too old to export
+    the helper, which also never creates the store, so it should and does answer False for
+    everything a real cache contains; it mirrors upstream's shape (real directory, regular
+    marker file, a layout version line) rather than trusting a filename, since otherwise a
+    hand-made marker would buy trust upstream itself would refuse.
     """
     try:
         from huggingface_hub.utils._shared_blobs import is_shared_blobs_dir
         return bool(is_shared_blobs_dir(path))
     except Exception:
         pass
+    marker = path / SHARED_BLOBS_MARKER_NAME
     try:
-        return path.is_dir() and (path / SHARED_BLOBS_MARKER_NAME).is_file()
+        if not stat_module.S_ISDIR(path.lstat().st_mode):
+            return False
+        if not stat_module.S_ISREG(marker.lstat().st_mode):
+            return False
+        return _SHARED_BLOBS_LAYOUT_RE.fullmatch(marker.read_text()) is not None
     except (OSError, ValueError):
         return False
 
@@ -179,17 +197,17 @@ def trusted_blob_roots(repo_dir: Path) -> tuple[Path, ...]:
     ``<cache_root>/blobs``: 1.32 turned each repo's ``blobs/<etag>`` into a symlink into that
     store, so a weight file resolves outside its repo folder without leaving the cache.
 
-    Both roots are RESOLVED, because callers compare them against a fully resolved candidate.
-    Comparing against a literal path silently rejects the very files it is meant to admit when
-    a ``blobs`` leaf is a symlink, which is how a big shared store ends up on another volume.
+    Both roots are RESOLVED, because callers compare them against a fully resolved candidate,
+    and a ``blobs`` leaf that is itself a symlink is not a root at all: hub refuses to adopt
+    one as its store, and honouring one here would anchor trust wherever the link points.
     """
     roots: list[Path] = []
-    own = _resolved_existing_dir(repo_dir / "blobs")
+    own = _resolved_real_dir(repo_dir / "blobs")
     if own is not None:
         roots.append(own)
     shared = repo_dir.parent / "blobs"
     if _is_hub_shared_blobs_dir(shared):
-        resolved_shared = _resolved_existing_dir(shared)
+        resolved_shared = _resolved_real_dir(shared)
         if resolved_shared is not None and resolved_shared not in roots:
             roots.append(resolved_shared)
     return tuple(roots)
diff --git a/studio/backend/tests/test_training_provenance.py b/studio/backend/tests/test_training_provenance.py
index 803239249..c4fe91cdf 100644
--- a/studio/backend/tests/test_training_provenance.py
+++ b/studio/backend/tests/test_training_provenance.py
@@ -685,6 +685,50 @@ def test_exact_model_snapshot_rejects_unmarked_blobs_dir(tmp_path):
     assert exact_model_snapshot_path(str(snapshot), "org/model") is None
 
 
+def test_exact_model_snapshot_rejects_symlinked_repo_blobs_dir(tmp_path):
+    """A repo's own ``blobs`` that is a symlink anchors trust wherever it points.
+
+    The containment test compares against a resolved root, so resolving a symlinked leaf
+    would make every file under its target an exact snapshot, including externally mutable
+    bytes that are not in the cache at all.
+    """
+    snapshot = _model_snapshot(tmp_path, "org/model", "linked-blobs", weights = False)
+    repo = snapshot.parent.parent
+    outside = tmp_path / "not-the-cache"
+    outside.mkdir()
+    (repo / "blobs").symlink_to(outside)
+    payload = outside / "etag"
+    payload.write_bytes(b"weights")
+    (snapshot / "model.safetensors").symlink_to(os.path.relpath(payload, snapshot))
+
+    assert exact_model_snapshot_path(str(snapshot), "org/model") is None
+
+
+def test_exact_model_snapshot_rejects_hand_made_shared_store_marker(tmp_path, monkeypatch):
+    """With no hub helper to ask, a bare marker filename must not buy trust.
+
+    The fallback only runs on a hub old enough to lack the store entirely, so anything it
+    sees is hand made; upstream requires a regular marker holding a layout version, and so
+    does this.
+    """
+    import sys
+
+    # None in sys.modules makes the import raise, which is the state a pre-1.32 hub presents.
+    monkeypatch.setitem(sys.modules, "huggingface_hub.utils._shared_blobs", None)
+    snapshot = _model_snapshot(tmp_path, "org/model", "hand-made", weights = False)
+    store = tmp_path / "blobs"
+    store.mkdir()
+    (store / ".huggingface-shared-blobs").write_text("not a layout version")
+    payload = store / "payload"
+    payload.write_bytes(b"weights")
+    repo_blob = snapshot.parent.parent / "blobs" / "etag"
+    repo_blob.parent.mkdir(exist_ok = True)
+    repo_blob.symlink_to(os.path.relpath(payload, repo_blob.parent))
+    (snapshot / "model.safetensors").symlink_to(os.path.relpath(repo_blob, snapshot))
+
+    assert exact_model_snapshot_path(str(snapshot), "org/model") is None
+
+
 def test_exact_model_snapshot_rejects_symlinked_shared_store(tmp_path):
     """A ``blobs`` leaf that is itself a symlink is not a store hub owns.

On screenshots

I ran the before/after Studio capture for this, because the effect is visible even though no frontend file changes: model_size_bytes is what training-config-store.ts gates the automatic LoRA/QLoRA choice on, so an unknown size does not degrade the choice, it skips it.

Two isolated installs at 768d644 and 1d941ba, one seeded huggingface_hub 1.32 cache holding unsloth/Llama-3.2-1B-Instruct with its weights in the shared store, same box, free VRAM equal on both sides. Read from each photographed server:

BEFORE 768d644 AFTER 1d941ba
model_size_bytes null 2471645608
vram_free_gb 178.35 178.35

I am not attaching the image pair, because it would not be evidence. The rendered Method box moved from QLoRA to LoRA on one run and stayed QLoRA on four more, from identical code: the auto-pick result is discarded unless training defaults are being applied and the method has not been marked edited (training-config-store.ts:454-457), and I could not make that a deterministic selection event from the driver. Two identical halves would read as "this PR changes nothing", which the number above says is false. The honest artifact here is the measurement.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants