Fix/adjust diffusion: token leak + cache guard + locked status + seed precision for PR #5754

- DiffusionBackend.status() now takes _lock so frontend polling
  cannot observe a torn snapshot mid-swap.
- Scrub hf_token / pipe_kwargs / single_file_kwargs from frame
  locals before logger.exception() so rich tracebacks and structlog
  formatters that render locals do not leak hf_... tokens into logs.
- routes/models.py delete_cached_repo: refuse to delete the cache
  underlying a currently-loaded diffusion pipeline (both the GGUF
  repo and the matching diffusers base_repo). Symmetric with the
  existing chat-load + GGUF guard.
- Frontend seed validation: reject non-integer and out-of-safe-
  integer-range inputs instead of silently rounding via Number(),
  which would otherwise send a different seed than what the user
  typed.
This commit is contained in:
Daniel Han-Chen 2026-05-25 00:36:29 +00:00
commit 0f3ed08351
3 changed files with 77 additions and 19 deletions

View file

@ -273,24 +273,33 @@ class DiffusionBackend:
return self._repo_id
def status(self) -> dict[str, Any]:
# Take _lock so the snapshot cannot observe a torn state where
# _pipe was already swapped but _family/_repo_id haven't been
# updated yet (or vice versa). Frontend polling at 1 Hz would
# otherwise render impossible "loaded but no repo_id" states.
# Only echo the GGUF basename; full absolute path leaks the
# local HF cache layout (and the system username on default
# POSIX layouts) to any authenticated Studio session.
gguf_basename = Path(self._gguf_path).name if self._gguf_path else None
return {
"is_loaded": self.is_loaded,
"is_loading": self._loading,
"repo_id": self._repo_id,
"family": self._family.name if self._family else None,
"pipeline_class": self._family.pipeline_class if self._family else None,
"base_repo": self._base_repo,
"gguf_filename": gguf_basename,
"device": self._device,
"dtype": self._dtype,
"loaded_at": self._loaded_at,
"last_error": self._last_error,
"supported_families": supported_families(),
}
with self._lock:
gguf_basename = (
Path(self._gguf_path).name if self._gguf_path else None
)
return {
"is_loaded": self._pipe is not None,
"is_loading": self._loading,
"repo_id": self._repo_id,
"family": self._family.name if self._family else None,
"pipeline_class": (
self._family.pipeline_class if self._family else None
),
"base_repo": self._base_repo,
"gguf_filename": gguf_basename,
"device": self._device,
"dtype": self._dtype,
"loaded_at": self._loaded_at,
"last_error": self._last_error,
"supported_families": supported_families(),
}
def _pick_device_and_dtype(self) -> tuple[str, "Any"]:
"""Pick (device, dtype) for the current host.
@ -506,6 +515,14 @@ class DiffusionBackend:
return self.status()
except Exception as exc:
# Scrub hf_token and pipe_kwargs from frame locals BEFORE
# logger.exception() captures them. Rich tracebacks and
# some structlog formatters render frame locals, which
# would otherwise echo the raw hf_... token into logs
# and any error reporting sink the user has wired up.
hf_token = None # noqa: F841
pipe_kwargs = None # noqa: F841
single_file_kwargs = None # noqa: F841
with self._lock:
self._last_error = str(exc)
logger.exception("Diffusion load failed for %s", repo_id)

View file

@ -2632,6 +2632,29 @@ async def delete_cached_model(
except Exception:
pass
# Also refuse to delete the cache underlying a loaded diffusion
# pipeline. The diffusion backend mmap's the GGUF + base repo
# weights and continues to read from the cache long after load,
# so deleting them out from under it would corrupt generation.
try:
from core.inference.diffusion import get_diffusion_backend
diff_backend = get_diffusion_backend()
diff_status = diff_backend.status()
if diff_status.get("is_loaded"):
diff_repo = (diff_status.get("repo_id") or "").lower()
diff_base = (diff_status.get("base_repo") or "").lower()
needle = repo_id.lower()
if diff_repo.startswith(needle) or diff_base.startswith(needle):
raise HTTPException(
status_code = 400,
detail = "Unload the diffusion image model before deleting",
)
except HTTPException:
raise
except Exception:
pass
try:
cache_scans = _all_hf_cache_scans()

View file

@ -200,10 +200,28 @@ export function ImagesPage() {
}
setBusy("generating");
try {
const parsedSeed = seed.trim() ? Number(seed.trim()) : undefined;
if (parsedSeed !== undefined && !Number.isFinite(parsedSeed)) {
toast.error("Seed must be a number");
return;
// Reject non-integer or out-of-safe-integer-range seeds rather
// than silently rounding via Number(). The backend takes an int
// and a precision loss here would yield a different image than
// the seed the user typed.
const seedStr = seed.trim();
let parsedSeed: number | undefined;
if (seedStr) {
if (!/^-?\d+$/.test(seedStr)) {
toast.error("Seed must be an integer");
return;
}
const candidate = Number(seedStr);
if (
!Number.isFinite(candidate) ||
!Number.isSafeInteger(candidate)
) {
toast.error(
"Seed must fit in a JavaScript safe integer (<= 2^53 - 1)",
);
return;
}
parsedSeed = candidate;
}
const out = await generateDiffusionImage({
prompt,