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:
parent
f134a19b87
commit
429ffa7392
4 changed files with 103 additions and 0 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue