Refuse deleting an in-use native companion repo while a GGUF is loaded

The native sd.cpp one-shot engine re-reads its companion VAE and text-encoder
files from the HF cache on every generation, but the delete-cached guard only
compared the loaded main diffusion repo_id. Deleting a companion repo such as
comfyanonymous/flux_text_encoders while a FLUX GGUF was loaded therefore
succeeded and broke the next generation. Add a loaded_repo_ids() accessor to the
native backend (mirroring loading_repo_ids(), reconstructed from the committed
family's VAE + text-encoder repos) and have the guard refuse those companions
too. Server mode and the diffusers engine hold companions in-process/VRAM, so
they are unaffected.
This commit is contained in:
Daniel Han 2026-07-08 00:08:55 +00:00
commit 429ffa7392
4 changed files with 103 additions and 0 deletions

View file

@ -716,6 +716,25 @@ class SdCppDiffusionBackend:
return ()
return tuple(r for r in (loading.repo_id, loading.base_repo, *loading.asset_repos) if r)
def loaded_repo_ids(self) -> tuple[str, ...]:
"""Repo ids the COMMITTED native model reads from disk (empty when unloaded).
The one-shot sd-cli re-reads the companion VAE / text-encoder files from the HF
cache on every generation (server mode keeps them in the resident process, but the
extra ids are harmless there), so the delete-cached guard must refuse those
companion repos while the model is loaded -- status().repo_id covers only the main
GGUF. Reconstructed from the committed family, mirroring loading_repo_ids()."""
with self._lock:
state = self._state
if state is None:
return ()
fam = state.family
repos = [state.repo_id, state.base_repo]
if fam.sd_cpp_vae:
repos.append(fam.sd_cpp_vae[0])
repos.extend(terepo for terepo, _f, _k in fam.sd_cpp_text_encoders)
return tuple(dict.fromkeys(r for r in repos if r))
# ── Generate ───────────────────────────────────────────────────────────
def generate(

View file

@ -3599,6 +3599,17 @@ async def delete_cached_model(
status_code = 400,
detail = "Unload the model before deleting",
)
# The native sd.cpp one-shot engine re-reads its companion VAE / text-encoder files
# from the HF cache on every generation, so deleting a companion repo (e.g.
# comfyanonymous/flux_text_encoders) while a native GGUF is loaded would brick the
# next generation. status().repo_id only covers the main GGUF, so also refuse the
# committed companion repos the loaded engine reads from disk.
for lid in getattr(engine, "loaded_repo_ids", tuple)():
if _loaded_id_matches_repo(str(lid).lower(), repo_id):
raise HTTPException(
status_code = 400,
detail = "Unload the model before deleting",
)
# Also refuse while a background image load is DOWNLOADING this repo (or its
# companion base): status().loaded is still False in that window, but deleting
# would remove blobs from under the in-flight download/assembly.

View file

@ -919,6 +919,62 @@ def test_delete_cached_refuses_video_loaded_repo(monkeypatch):
assert "Unload the model before deleting" in e.detail
def test_delete_cached_refuses_loaded_native_companion_repo(monkeypatch):
# The native sd.cpp one-shot engine re-reads its companion VAE / text-encoder files from the
# HF cache on every generation, so deleting a companion repo (comfyanonymous/flux_text_encoders)
# while a FLUX GGUF is loaded must be refused. The loaded main repo_id does not match the
# companion, so the guard relies on loaded_repo_ids() to cover the committed companions.
from fastapi import HTTPException
import core.inference.diffusion_engine_router as der
import core.inference.video as video_mod
import routes.inference as routes_inference
monkeypatch.setattr(
routes_inference,
"get_llama_cpp_backend",
lambda: SimpleNamespace(is_loaded = False, model_identifier = None),
)
monkeypatch.setattr(
models_route,
"get_inference_backend",
lambda: SimpleNamespace(active_model_name = None),
)
monkeypatch.setattr(
der,
"get_active_diffusion_engine",
lambda: SimpleNamespace(
status = lambda: {"loaded": True, "repo_id": "unsloth/FLUX.1-dev-GGUF"},
loaded_repo_ids = lambda: (
"unsloth/FLUX.1-dev-GGUF",
"black-forest-labs/FLUX.1-dev",
"comfyanonymous/flux_text_encoders",
),
loading_repo_ids = lambda: (),
),
)
monkeypatch.setattr(
video_mod,
"get_video_backend",
lambda: SimpleNamespace(
status = lambda: {"loaded": False, "repo_id": None},
loading_repo_ids = lambda: (),
),
)
try:
asyncio.run(
models_route.delete_cached_model(
repo_id = "comfyanonymous/flux_text_encoders",
variant = None,
current_subject = "u",
)
)
assert False, "expected HTTPException refusing the in-use companion delete"
except HTTPException as e:
assert e.status_code == 400
assert "Unload the model before deleting" in e.detail
def test_delete_cached_allows_sibling_of_loaded_diffusion_repo(monkeypatch):
# A loaded Images repo must not block deleting a DIFFERENT cached repo that merely shares a
# name prefix. Qwen/Qwen-Image and Qwen/Qwen-Image-2512 are both real catalog artifacts, so

View file

@ -82,6 +82,23 @@ def _loaded_backend(fam_name = "z-image", engine = None):
return b
def test_loaded_repo_ids_includes_native_companions():
# The one-shot native engine re-reads its companion VAE / text-encoder files from the HF
# cache on every generation, so the delete-cached guard queries loaded_repo_ids() to refuse
# deleting an in-use companion repo. It must surface the committed family's VAE + text-encoder
# repos (plus the main + base repos), not just the loaded GGUF, and be empty once unloaded.
b = _loaded_backend("flux.1")
ids = set(b.loaded_repo_ids())
fam = detect_family("flux.1")
assert "unsloth/Z-Image-Turbo-GGUF" in ids # the main GGUF repo
assert fam.base_repo in ids
assert fam.sd_cpp_vae[0] in ids # black-forest-labs/FLUX.1-schnell (VAE)
for terepo, _f, _k in fam.sd_cpp_text_encoders:
assert terepo in ids # comfyanonymous/flux_text_encoders
b._state = None
assert b.loaded_repo_ids() == ()
class _FakeServer:
"""Stands in for SdCppServer: records the spawn + one img_gen per whole batch."""