Bound the gallery blob cache, and three interlock fixes
Four review findings, all reproduced first: - The gallery object-URL caches were unbounded. A clip runs from a few MB to a few hundred, both pages stay mounted after their first visit, and entries were only dropped on delete, so scrolling pinned everything for the session. Both pages now share a byte-budgeted LRU (512 MB video / 192 MB images) keyed off the visibility signal the near-viewport fetching already provides. On-screen media, the selected clip or image, and the item just fetched are never evicted, so eviction is invisible and a single item larger than the whole budget cannot evict itself into a refetch loop. - The image, video and chat load guards ran two independent training probes but returned early when the FIRST one raised, so an unreadable LLM backend disabled the diffusion interlock and a load could proceed straight into an active diffusion trainer on the same GPU. The probes are independent now. - An engine switch swallowed a failed teardown and published the new engine anyway, which is exactly the leak the unload exists to prevent: the arbiter's evictor, /images/unload and the next load all resolve through get_active_diffusion_engine(), so the still-resident pipeline (or a live sd-server) became unreachable and the next load allocated on top of it. The switch now fails and leaves the old engine published, so it stays reclaimable. - The native generation timeout was 30 minutes while the Images page waits up to 6 hours (SETTLE_MAX_MS), so slow-but-progressing CPU jobs died deterministically at the deadline. Measured on GPU-less runners, a 512x512 4-step Q2_K generation took 900 s on Linux and 1465 s on Windows, so larger images or step counts clear half an hour easily. The ceiling now matches the page's window and applies to the whole request: chunks of a split batch share one deadline instead of each getting a full budget. Cancellation is unchanged. Declined: gating the huggingfacenotorch extra off Python 3.9 over the conditional diffusers marker. The marker is deliberate and its comment says why: diffusers dropped 3.9 in 0.38, so pinning >=0.39 outright leaves pip no candidate and the whole extra unresolvable there. The pipelines it names live in studio/backend, which cannot install on 3.9 anyway (studio.txt pins matplotlib==3.10.9 and fastmcp>=3.0.2, both requires_python >=3.10), and the extra is the general core one, so the alternative drops 3.9 for library users who never touch Studio.
This commit is contained in:
parent
c6e5a36534
commit
3f6057a2b2
16 changed files with 359 additions and 50 deletions
|
|
@ -110,8 +110,19 @@ def _activate(name: str, reason: Optional[str]) -> Any:
|
|||
# evict the new (empty) engine while the old model is still freeing VRAM.
|
||||
try:
|
||||
engine_to_unload.unload()
|
||||
except Exception as exc: # noqa: BLE001 -- best-effort; never block the switch
|
||||
logger.warning("failed to unload previous engine %s: %s", old_name, exc)
|
||||
except Exception as exc:
|
||||
# Do NOT publish the new engine after a failed teardown. The old model (or the
|
||||
# resident sd-server process) is still holding its memory, and flipping the name
|
||||
# would make it unreachable through get_active_diffusion_engine(): the arbiter's
|
||||
# evictor, /images/unload and the next load all resolve through that, so the
|
||||
# leak would be permanent and the next load would allocate on top of it. Leaving
|
||||
# the old engine active keeps it reclaimable and lets the caller retry.
|
||||
logger.error("failed to unload previous engine %s: %s", old_name, exc)
|
||||
raise RuntimeError(
|
||||
f"Could not switch the diffusion engine to {name}: unloading the current "
|
||||
f"{old_name} model failed ({exc}). The current model is still loaded; "
|
||||
"unload it and try again."
|
||||
) from exc
|
||||
with _lock:
|
||||
_active_engine_name = name
|
||||
_fallback_reason = reason if name == ENGINE_DIFFUSERS else None
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ from core.inference.sd_cpp_args import (
|
|||
offload_flags,
|
||||
)
|
||||
from core.inference.sd_cpp_engine import (
|
||||
NATIVE_GENERATION_TIMEOUT_S,
|
||||
SdCppCancelled,
|
||||
SdCppEngine,
|
||||
find_sd_cpp_binary,
|
||||
|
|
@ -80,9 +81,6 @@ _install_lock = threading.Lock()
|
|||
# Max images per img_gen job; larger Studio batches (up to 32) are split into these chunks.
|
||||
_MAX_SERVER_BATCH = 8
|
||||
|
||||
# Per-image server-job budget, so a batch's timeout scales with image count.
|
||||
_SERVER_PER_IMAGE_TIMEOUT_S = 1800.0
|
||||
|
||||
|
||||
def _default_threads() -> int:
|
||||
"""Physical-core thread count for the sd.cpp CPU backend.
|
||||
|
|
@ -928,6 +926,11 @@ class SdCppDiffusionBackend:
|
|||
}
|
||||
for m in materialized
|
||||
]
|
||||
# One deadline for the whole request, shared by its chunks: a batch is chunked only because
|
||||
# the server caps images per job, so giving each chunk its own full budget would let a batch
|
||||
# run for a multiple of the window the page is still waiting on. Each chunk gets whatever is
|
||||
# left, so a slow single image can use all of it and a long batch still ends on time.
|
||||
deadline = time.monotonic() + NATIVE_GENERATION_TIMEOUT_S
|
||||
try:
|
||||
for offset in range(0, total, _MAX_SERVER_BATCH):
|
||||
if cancel.is_set():
|
||||
|
|
@ -952,7 +955,7 @@ class SdCppDiffusionBackend:
|
|||
payload,
|
||||
on_step = self._on_log,
|
||||
cancel_event = cancel,
|
||||
total_timeout = _SERVER_PER_IMAGE_TIMEOUT_S * count,
|
||||
total_timeout = max(deadline - time.monotonic(), 1.0),
|
||||
)
|
||||
# All-or-nothing per chunk: fail rather than silently drop images from the batch.
|
||||
if not cancel.is_set() and len(blobs) != count:
|
||||
|
|
|
|||
|
|
@ -44,6 +44,14 @@ _LEGACY_STEM = "sd"
|
|||
# The persistent HTTP server target, shipped next to sd-cli in both prebuilt and cmake builds.
|
||||
_SERVER_STEM = "sd-server"
|
||||
|
||||
# Ceiling for one native run. The native engine exists FOR slow CPU hosts: measured on GPU-less
|
||||
# CI runners, a 512x512 4-step Q2_K generation took 900 s on Linux and 1465 s on Windows, so a
|
||||
# larger image or step count clears half an hour easily and a 30-minute cap killed jobs that were
|
||||
# still progressing. This matches the Images page's own SETTLE_MAX_MS (6 h), past which the UI has
|
||||
# given up anyway, so the ceiling only stops a WEDGED process from holding the lock forever.
|
||||
# It is not the user-facing abort path: cancel_event interrupts a run at any point.
|
||||
NATIVE_GENERATION_TIMEOUT_S = 6 * 60 * 60.0
|
||||
|
||||
|
||||
class SdCppCancelled(RuntimeError):
|
||||
"""A generation cancelled via its ``cancel_event`` (unload / superseding load / arbiter
|
||||
|
|
@ -253,7 +261,7 @@ class SdCppEngine:
|
|||
threads: Optional[int] = None,
|
||||
verbose: bool = False,
|
||||
extra_args: Optional[list[str]] = None,
|
||||
timeout: Optional[float] = 1800.0,
|
||||
timeout: Optional[float] = NATIVE_GENERATION_TIMEOUT_S,
|
||||
env: Optional[dict[str, str]] = None,
|
||||
on_log: Optional[Callable[[str], None]] = None,
|
||||
cancel_event: Optional[threading.Event] = None,
|
||||
|
|
@ -294,7 +302,7 @@ class SdCppEngine:
|
|||
output_path: str,
|
||||
verbose: bool = False,
|
||||
extra_args: Optional[list[str]] = None,
|
||||
timeout: Optional[float] = 1800.0,
|
||||
timeout: Optional[float] = NATIVE_GENERATION_TIMEOUT_S,
|
||||
env: Optional[dict[str, str]] = None,
|
||||
on_log: Optional[Callable[[str], None]] = None,
|
||||
cancel_event: Optional[threading.Event] = None,
|
||||
|
|
|
|||
|
|
@ -46,7 +46,11 @@ from typing import Any, Callable, Optional
|
|||
import httpx
|
||||
|
||||
from core.inference.sd_cpp_args import SdCppModelFiles, build_sd_cpp_server_command
|
||||
from core.inference.sd_cpp_engine import SdCppCancelled, runtime_env
|
||||
from core.inference.sd_cpp_engine import (
|
||||
NATIVE_GENERATION_TIMEOUT_S,
|
||||
SdCppCancelled,
|
||||
runtime_env,
|
||||
)
|
||||
from utils.native_path_leases import child_env_without_native_path_secret
|
||||
from utils.process_lifetime import adopt_pid, child_popen_kwargs, forget_pid
|
||||
from utils.subprocess_compat import windows_hidden_subprocess_kwargs
|
||||
|
|
@ -366,7 +370,7 @@ class SdCppServer:
|
|||
cancel_event: Optional[threading.Event] = None,
|
||||
poll_interval: float = 0.4,
|
||||
submit_timeout: float = 60.0,
|
||||
total_timeout: float = 1800.0,
|
||||
total_timeout: float = NATIVE_GENERATION_TIMEOUT_S,
|
||||
) -> list[bytes]:
|
||||
"""Submit one async ``img_gen`` job, poll it to completion, return image bytes.
|
||||
|
||||
|
|
|
|||
|
|
@ -4048,8 +4048,10 @@ def _guard_chat_load_against_training(
|
|||
try:
|
||||
llm_active = get_training_backend().is_training_active()
|
||||
except Exception as e:
|
||||
# Independent probes: an unreadable LLM backend must still fall through to the diffusion
|
||||
# check below, which reads a different service and may know a trainer IS running.
|
||||
logger.warning("Could not check training state for chat-load guard: %s", e)
|
||||
return
|
||||
llm_active = False
|
||||
|
||||
if not llm_active:
|
||||
# An SDXL LoRA trainer runs in its own subprocess and its VRAM can't be cheaply fit-checked here,
|
||||
|
|
@ -16008,8 +16010,10 @@ def _guard_diffusion_load_against_training() -> None:
|
|||
try:
|
||||
llm_active = get_training_backend().is_training_active()
|
||||
except Exception as e:
|
||||
# The two probes are independent: an unreadable LLM backend must not disable the diffusion
|
||||
# interlock below, which reads a different service and may well know a trainer IS running.
|
||||
logger.warning("Could not check training state for image-load guard: %s", e)
|
||||
return
|
||||
llm_active = False
|
||||
# An SDXL LoRA trainer runs in its own subprocess on the same GPU, so an image load must be
|
||||
# refused while one is active or the pipeline contends with the trainer for VRAM. Symmetric with
|
||||
# the diffusion-start interlock.
|
||||
|
|
|
|||
|
|
@ -53,8 +53,10 @@ def _guard_video_load_against_training() -> None:
|
|||
try:
|
||||
llm_active = get_training_backend().is_training_active()
|
||||
except Exception as e: # noqa: BLE001
|
||||
# Independent probes: an unreadable LLM backend must not disable the diffusion interlock
|
||||
# below, which reads a different service and may know a trainer IS running.
|
||||
logger.warning("Could not check training state for video-load guard: %s", e)
|
||||
return
|
||||
llm_active = False
|
||||
diffusion_active = False
|
||||
try:
|
||||
from core.training.diffusion_training_service import get_diffusion_training_service
|
||||
|
|
|
|||
|
|
@ -27,10 +27,14 @@ def _clean_env_and_state(monkeypatch):
|
|||
monkeypatch.delenv(e, raising = False)
|
||||
# A light status-capable stub so neither selection nor active_status() imports the heavy
|
||||
# diffusers/sd.cpp backends; the active engine NAME comes from module state.
|
||||
# unload() is part of the engine contract (a switch tears the old engine down and now refuses to
|
||||
# publish the new one if that fails), so the stub has to honour it.
|
||||
monkeypatch.setattr(
|
||||
r,
|
||||
"get_active_diffusion_engine",
|
||||
lambda: SimpleNamespace(status = lambda: {"loaded": False, "repo_id": None}),
|
||||
lambda: SimpleNamespace(
|
||||
status = lambda: {"loaded": False, "repo_id": None}, unload = lambda: None
|
||||
),
|
||||
)
|
||||
# Default: no resident sd-server (so existing tests exercise the sd-cli path) and a stubbed
|
||||
# runnability probe, so neither reaches the real install/exec path.
|
||||
|
|
@ -326,3 +330,23 @@ def test_begin_load_on_holds_the_transition_lock_while_registering(monkeypatch):
|
|||
assert r._transition_lock.locked()
|
||||
release.set()
|
||||
t.join(2.0)
|
||||
|
||||
|
||||
def test_switch_aborts_when_the_old_engine_fails_to_unload(monkeypatch):
|
||||
# A swallowed teardown failure published the new engine anyway, which stranded the old model:
|
||||
# the arbiter's evictor, /images/unload and the next load all resolve through
|
||||
# get_active_diffusion_engine(), so nothing could reach the still-resident pipeline (or a live
|
||||
# sd-server) to reclaim it, and the next load allocated on top of it. Keep the old engine
|
||||
# published and fail the switch instead.
|
||||
def _fake_engine():
|
||||
def _boom():
|
||||
raise RuntimeError("sd-server would not die")
|
||||
|
||||
return SimpleNamespace(unload = _boom, status = lambda: {"loaded": True, "repo_id": "x"})
|
||||
|
||||
monkeypatch.setattr(r, "get_active_diffusion_engine", lambda: _fake_engine())
|
||||
r._active_engine_name = ENGINE_SD_CPP
|
||||
with pytest.raises(RuntimeError, match = "Could not switch the diffusion engine"):
|
||||
r._activate(ENGINE_DIFFUSERS, "switch test")
|
||||
# Still the old engine, so the resident model remains reachable and reclaimable.
|
||||
assert r.active_engine_name() == ENGINE_SD_CPP
|
||||
|
|
|
|||
|
|
@ -1063,3 +1063,33 @@ def test_download_plan_forwards_the_load_time_controls(client, monkeypatch):
|
|||
assert seen["memory_mode"] == "low_vram"
|
||||
assert seen["cpu_offload"] is True
|
||||
assert len(seen["loras"] or []) == 1
|
||||
|
||||
|
||||
def test_load_refused_when_only_the_diffusion_probe_can_be_read(client, monkeypatch):
|
||||
# The two training probes are independent. An LLM backend that raises used to short-circuit the
|
||||
# guard entirely, so an image load sailed past a KNOWN-active diffusion trainer and contended
|
||||
# with it for VRAM. An unreadable LLM state must not disable the diffusion interlock.
|
||||
import core.training as core_training
|
||||
import routes.inference as inference_routes
|
||||
|
||||
class _Broken:
|
||||
def is_training_active(self):
|
||||
raise RuntimeError("training backend unavailable")
|
||||
|
||||
monkeypatch.setattr(core_training, "get_training_backend", lambda: _Broken())
|
||||
monkeypatch.setattr(inference_routes, "_diffusion_training_active", lambda: True)
|
||||
|
||||
resp = client.post(
|
||||
"/api/inference/images/load",
|
||||
json = {"model_path": "x/z-image", "gguf_filename": "q.gguf"},
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
assert "training" in resp.json()["detail"].lower()
|
||||
|
||||
# With neither trainer active the unreadable LLM probe still must not block the load.
|
||||
monkeypatch.setattr(inference_routes, "_diffusion_training_active", lambda: False)
|
||||
resp = client.post(
|
||||
"/api/inference/images/load",
|
||||
json = {"model_path": "x/z-image", "gguf_filename": "q.gguf"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
|
|
|||
|
|
@ -577,11 +577,14 @@ def test_server_generate_splits_batches_above_server_limit(monkeypatch):
|
|||
assert len(out["images"]) == 10
|
||||
counts = [p["batch_count"] for p in servers[0].payloads]
|
||||
assert counts == [bk._MAX_SERVER_BATCH, 10 - bk._MAX_SERVER_BATCH] # [8, 2]
|
||||
# Each chunk's timeout scales with its image count, not one fixed batch deadline.
|
||||
assert servers[0].timeouts == [
|
||||
bk._SERVER_PER_IMAGE_TIMEOUT_S * 8,
|
||||
bk._SERVER_PER_IMAGE_TIMEOUT_S * 2,
|
||||
]
|
||||
# Chunks share ONE request deadline rather than each getting a full budget: a batch is split
|
||||
# only because the server caps images per job, so per-chunk budgets would let a batch outlive
|
||||
# the window the page is still waiting on. Each chunk therefore gets what is left, which is at
|
||||
# most the ceiling and never increases.
|
||||
assert servers[0].timeouts[0] <= bk.NATIVE_GENERATION_TIMEOUT_S
|
||||
assert servers[0].timeouts[1] <= servers[0].timeouts[0]
|
||||
# A single slow image can still use the whole window (the old per-image cap was 30 minutes).
|
||||
assert servers[0].timeouts[-1] > 1800.0
|
||||
# Seeds run contiguously across chunks (chunk 2 submitted at base + 8).
|
||||
assert out["seeds"] == list(range(100, 110))
|
||||
assert servers[0].payloads[1]["seed"] == 108
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ PNG -- no real ``sd-cli``, no GPU.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
|
@ -491,3 +492,21 @@ def test_routing_prefer_native_overrides_gpu():
|
|||
select_diffusion_engine("cuda", native_available = False, prefer_native = True)
|
||||
== ENGINE_DIFFUSERS
|
||||
)
|
||||
|
||||
|
||||
def test_native_generation_timeout_matches_the_ui_settle_window():
|
||||
# The native engine exists for slow CPU hosts: measured on GPU-less CI runners a 512x512 4-step
|
||||
# Q2_K generation took 900 s (Linux) and 1465 s (Windows), so the old 30-minute default killed
|
||||
# still-progressing jobs at higher resolutions or step counts while the Images page waited hours
|
||||
# for them. The ceiling now matches that page's SETTLE_MAX_MS.
|
||||
from core.inference.sd_cpp_engine import NATIVE_GENERATION_TIMEOUT_S, SdCppEngine
|
||||
from core.inference import sd_cpp_backend
|
||||
|
||||
assert NATIVE_GENERATION_TIMEOUT_S == 6 * 60 * 60
|
||||
for fn in (SdCppEngine.generate, SdCppEngine.upscale):
|
||||
assert (
|
||||
inspect.signature(fn).parameters["timeout"].default == NATIVE_GENERATION_TIMEOUT_S
|
||||
), fn.__name__
|
||||
# The resident-server path shares the same ceiling (applied per request, see
|
||||
# test_server_generate_splits_batches_above_server_limit).
|
||||
assert sd_cpp_backend.NATIVE_GENERATION_TIMEOUT_S == NATIVE_GENERATION_TIMEOUT_S
|
||||
|
|
|
|||
|
|
@ -836,3 +836,33 @@ def test_video_download_plan_forwards_the_encoder_policy(client, monkeypatch):
|
|||
assert resp.status_code == 200
|
||||
assert seen["text_encoder_quant"] == "fp8"
|
||||
assert seen["hf_token"] == "hf_secret"
|
||||
|
||||
|
||||
def test_video_load_guard_still_checks_diffusion_when_the_llm_probe_raises(client, monkeypatch):
|
||||
# Same independence rule as the image guard: a raising LLM probe used to return early, so a
|
||||
# video load ran straight into an active diffusion trainer on the same GPU.
|
||||
import core.training as core_training
|
||||
import routes.video as video_routes
|
||||
|
||||
class _Broken:
|
||||
def is_training_active(self):
|
||||
raise RuntimeError("training backend unavailable")
|
||||
|
||||
class _Diffusion:
|
||||
def is_active(self):
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(core_training, "get_training_backend", lambda: _Broken())
|
||||
monkeypatch.setattr(
|
||||
"core.training.diffusion_training_service.get_diffusion_training_service",
|
||||
lambda: _Diffusion(),
|
||||
raising = False,
|
||||
)
|
||||
|
||||
resp = client.post(
|
||||
"/api/inference/video/load",
|
||||
json = {"model_path": "unsloth/LTX-2.3-GGUF", "gguf_filename": "q.gguf"},
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
assert "training" in resp.json()["detail"].lower()
|
||||
assert video_routes is not None
|
||||
|
|
|
|||
|
|
@ -353,10 +353,15 @@ export async function clearGallery(): Promise<void> {
|
|||
|
||||
/** Fetch a gallery PNG (auth-protected, so it can't be a plain <img src>) and
|
||||
* wrap it in an object URL. Callers must revoke the URL when done. */
|
||||
export async function fetchGalleryObjectUrl(url: string): Promise<string> {
|
||||
export async function fetchGalleryObjectUrl(
|
||||
url: string,
|
||||
): Promise<{ url: string; bytes: number }> {
|
||||
const res = await authFetch(url);
|
||||
if (!res.ok) throw new Error(await readFastApiError(res));
|
||||
return URL.createObjectURL(await res.blob());
|
||||
// The blob's size travels with the URL: the gallery cache is budgeted in bytes (see
|
||||
// BlobUrlCache), which the caller cannot work out from the URL alone.
|
||||
const blob = await res.blob();
|
||||
return { url: URL.createObjectURL(blob), bytes: blob.size };
|
||||
}
|
||||
|
||||
// ── Diffusion LoRA training ───────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ import { ModelLoadDescription } from "@/features/chat/components/model-load-stat
|
|||
import { getHfToken, hfApiToken } from "@/features/hub/stores/hf-token-store";
|
||||
import { formatBytes, formatEta } from "@/features/hub/lib/format";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { BlobUrlCache } from "@/lib/blob-url-cache";
|
||||
import { diffusionRoutePick } from "@/lib/diffusion-route-pick";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
||||
|
|
@ -280,12 +281,18 @@ function matchAspect(width: number, height: number): { key: string; portrait: bo
|
|||
// Module cache of the backend-persisted gallery, so a tab switch re-renders
|
||||
// instantly. Object URLs are revoked only on delete (not unmount), so they stay
|
||||
// valid across remounts.
|
||||
// Blob budget for cached gallery PNGs. A 1024x1024 PNG is ~1-2 MB, and the page stays mounted
|
||||
// after its first visit, so an unbounded cache grew for the whole session as the user scrolled.
|
||||
// 192 MB is ~100-200 images: far more than any viewport holds, while capping what the webview
|
||||
// can be made to pin. On-screen and open-in-the-viewer images are never evicted.
|
||||
const IMAGE_BLOB_BUDGET_BYTES = 192 * 1024 * 1024;
|
||||
|
||||
const galleryCache: {
|
||||
images: GalleryImage[];
|
||||
hasMore: boolean;
|
||||
selectedId: string | null;
|
||||
quant: string | null;
|
||||
srcById: Map<string, string>;
|
||||
srcById: BlobUrlCache;
|
||||
// Ids with a fetch in flight, so concurrent ensureSrc calls don't double-fetch
|
||||
// (and leak the duplicate object URL).
|
||||
inflight: Set<string>;
|
||||
|
|
@ -298,7 +305,7 @@ const galleryCache: {
|
|||
hasMore: false,
|
||||
selectedId: null,
|
||||
quant: null,
|
||||
srcById: new Map(),
|
||||
srcById: new BlobUrlCache(IMAGE_BLOB_BUDGET_BYTES),
|
||||
inflight: new Set(),
|
||||
deleted: new Set(),
|
||||
};
|
||||
|
|
@ -1193,13 +1200,16 @@ export function ImagesPage({ active = true }: { active?: boolean }) {
|
|||
const [hasMore, setHasMore] = useState(() => galleryCache.hasMore);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(() => galleryCache.selectedId);
|
||||
const [srcById, setSrcById] = useState<Record<string, string>>(() =>
|
||||
Object.fromEntries(galleryCache.srcById),
|
||||
galleryCache.srcById.toRecord(),
|
||||
);
|
||||
// Guards a "load more" so a fast scroll can't fire several at once.
|
||||
const loadingMore = useRef(false);
|
||||
// The gallery strip, used as the IntersectionObserver root so a tile's PNG is fetched as it
|
||||
// nears view instead of every tile of every page up front.
|
||||
const stripRef = useRef<HTMLDivElement | null>(null);
|
||||
// Ids currently intersecting the strip. The blob cache never evicts these, so pruning cannot
|
||||
// pull an image out from under a visible tile.
|
||||
const visibleIds = useRef<Set<string>>(new Set());
|
||||
// False once the page truly unmounts (app close / chat-only eject). The page now
|
||||
// stays mounted across tab switches, so a switch does NOT flip this -- a batch keeps
|
||||
// generating off-tab; the multi-run loop only stops on a real unmount.
|
||||
|
|
@ -1372,13 +1382,23 @@ export function ImagesPage({ active = true }: { active?: boolean }) {
|
|||
if (galleryCache.srcById.has(image.id) || galleryCache.inflight.has(image.id)) return;
|
||||
galleryCache.inflight.add(image.id);
|
||||
try {
|
||||
const url = await fetchGalleryObjectUrl(image.url);
|
||||
const { url, bytes } = await fetchGalleryObjectUrl(image.url);
|
||||
if (galleryCache.deleted.has(image.id)) {
|
||||
URL.revokeObjectURL(url);
|
||||
return;
|
||||
}
|
||||
galleryCache.srcById.set(image.id, url);
|
||||
setSrcById((prev) => ({ ...prev, [image.id]: url }));
|
||||
galleryCache.srcById.set(image.id, url, bytes);
|
||||
// Evict the coldest off-screen images this one pushed over budget. On-screen tiles and the
|
||||
// image open in the viewer are protected, so eviction is never visible; an evicted tile
|
||||
// re-fetches when it is scrolled back to.
|
||||
const evicted = galleryCache.srcById.prune(
|
||||
new Set([image.id, ...visibleIds.current, galleryCache.selectedId ?? ""]),
|
||||
);
|
||||
setSrcById((prev) => {
|
||||
const next = { ...prev, [image.id]: url };
|
||||
for (const id of evicted) delete next[id];
|
||||
return next;
|
||||
});
|
||||
} catch {
|
||||
// Leave it without a src; the tile shows a placeholder.
|
||||
} finally {
|
||||
|
|
@ -1441,9 +1461,17 @@ export function ImagesPage({ active = true }: { active?: boolean }) {
|
|||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
if (!entry.isIntersecting) continue;
|
||||
const id = (entry.target as HTMLElement).dataset.imageId;
|
||||
const image = id ? images.find((i) => i.id === id) : undefined;
|
||||
if (!id) continue;
|
||||
// Visibility is also the cache's recency and protection signal: an on-screen tile is
|
||||
// never evicted, and leaving the viewport makes it a candidate again.
|
||||
if (!entry.isIntersecting) {
|
||||
visibleIds.current.delete(id);
|
||||
continue;
|
||||
}
|
||||
visibleIds.current.add(id);
|
||||
galleryCache.srcById.touch(id);
|
||||
const image = images.find((i) => i.id === id);
|
||||
if (image) void ensureSrc(image);
|
||||
}
|
||||
},
|
||||
|
|
@ -1477,9 +1505,8 @@ export function ImagesPage({ active = true }: { active?: boolean }) {
|
|||
toast.error(err instanceof Error ? err.message : "Failed to delete image");
|
||||
return;
|
||||
}
|
||||
const url = galleryCache.srcById.get(id);
|
||||
if (url?.startsWith("blob:")) URL.revokeObjectURL(url);
|
||||
galleryCache.srcById.delete(id);
|
||||
galleryCache.srcById.delete(id); // revokes the URL with the entry
|
||||
visibleIds.current.delete(id);
|
||||
// A fetch still in flight for this id must discard its blob rather than cache it.
|
||||
galleryCache.deleted.add(id);
|
||||
setSrcById((prev) => {
|
||||
|
|
|
|||
|
|
@ -250,10 +250,15 @@ export async function clearVideoGallery(): Promise<void> {
|
|||
|
||||
/** Fetch a gallery MP4 (auth-protected, so it can't be a plain <video src>) and wrap it
|
||||
* in an object URL. Callers must revoke the URL when done. Mirrors the images gallery. */
|
||||
export async function fetchGalleryVideoObjectUrl(url: string): Promise<string> {
|
||||
export async function fetchGalleryVideoObjectUrl(
|
||||
url: string,
|
||||
): Promise<{ url: string; bytes: number }> {
|
||||
const res = await authFetch(url);
|
||||
if (!res.ok) throw new Error(await readFastApiError(res));
|
||||
return URL.createObjectURL(await res.blob());
|
||||
// The blob's size travels with the URL: the gallery cache is budgeted in bytes, and a clip's
|
||||
// size varies by two orders of magnitude, so the caller cannot estimate it.
|
||||
const blob = await res.blob();
|
||||
return { url: URL.createObjectURL(blob), bytes: blob.size };
|
||||
}
|
||||
|
||||
/** Server-side transcode for the Download menu (WebM / GIF). The backend 501s
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ import { formatBytes, formatEta } from "@/features/hub/lib/format";
|
|||
import { useNavigate, useSearch } from "@tanstack/react-router";
|
||||
import { useStagedDownload } from "@/features/hub/download-manager";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { BlobUrlCache } from "@/lib/blob-url-cache";
|
||||
import { diffusionRoutePick } from "@/lib/diffusion-route-pick";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
||||
|
|
@ -124,14 +125,22 @@ const FALLBACK_RESOLUTION_PRESETS: Array<[number, number]> = [
|
|||
const FALLBACK_FRAME_STEP = 8;
|
||||
const FALLBACK_FPS = 24;
|
||||
|
||||
// Blob budget for cached clips. A clip runs from a few MB to a few hundred MB, and the page
|
||||
// stays mounted after its first visit, so an unbounded cache pinned everything the user ever
|
||||
// scrolled past for the rest of the session. 512 MB holds a comfortable working set (the strip's
|
||||
// visible cards plus their neighbours) while capping what the webview can be made to hold; the
|
||||
// playing and on-screen clips are never evicted, and anything dropped re-fetches on scroll-back.
|
||||
const VIDEO_BLOB_BUDGET_BYTES = 512 * 1024 * 1024;
|
||||
|
||||
// Module cache of the backend-persisted gallery, so a tab switch re-renders instantly.
|
||||
// Object URLs are revoked only on delete (not unmount), so they stay valid across remounts.
|
||||
// Object URLs are revoked only on delete/eviction (not unmount), so they stay valid across
|
||||
// remounts.
|
||||
const galleryCache: {
|
||||
videos: GalleryVideo[];
|
||||
hasMore: boolean;
|
||||
selectedId: string | null;
|
||||
quant: string | null;
|
||||
srcById: Map<string, string>;
|
||||
srcById: BlobUrlCache;
|
||||
// Ids with a fetch in flight, so concurrent ensureSrc calls don't double-fetch
|
||||
// (and leak the duplicate object URL).
|
||||
inflight: Set<string>;
|
||||
|
|
@ -146,7 +155,7 @@ const galleryCache: {
|
|||
hasMore: false,
|
||||
selectedId: null,
|
||||
quant: null,
|
||||
srcById: new Map(),
|
||||
srcById: new BlobUrlCache(VIDEO_BLOB_BUDGET_BYTES),
|
||||
inflight: new Set(),
|
||||
deleted: new Set(),
|
||||
epoch: 0,
|
||||
|
|
@ -572,7 +581,7 @@ export function VideoPage({ active = true }: { active?: boolean }) {
|
|||
if (!active) previewRef.current?.pause();
|
||||
}, [active]);
|
||||
const [srcById, setSrcById] = useState<Record<string, string>>(() =>
|
||||
Object.fromEntries(galleryCache.srcById),
|
||||
galleryCache.srcById.toRecord(),
|
||||
);
|
||||
// Guards a "load more" so a fast scroll can't fire several at once.
|
||||
const loadingMore = useRef(false);
|
||||
|
|
@ -696,7 +705,7 @@ export function VideoPage({ active = true }: { active?: boolean }) {
|
|||
galleryCache.inflight.add(video.id);
|
||||
const epochAtStart = galleryCache.epoch;
|
||||
try {
|
||||
const url = await fetchGalleryVideoObjectUrl(video.url);
|
||||
const { url, bytes } = await fetchGalleryVideoObjectUrl(video.url);
|
||||
// The record can be deleted (or the gallery cleared) while its MP4 is downloading. The
|
||||
// delete handler revoked whatever URL existed then, so caching this one would pin a blob
|
||||
// no card can ever release.
|
||||
|
|
@ -704,11 +713,22 @@ export function VideoPage({ active = true }: { active?: boolean }) {
|
|||
URL.revokeObjectURL(url);
|
||||
return;
|
||||
}
|
||||
galleryCache.srcById.set(video.id, url);
|
||||
galleryCache.srcById.set(video.id, url, bytes);
|
||||
// Evict the coldest off-screen clips this one pushed over budget. On-screen cards and the
|
||||
// clip in the player are protected, so eviction is never visible; an evicted card re-fetches
|
||||
// if it is scrolled back to. The clip just fetched is protected too, or a single clip larger
|
||||
// than the whole budget would evict itself and re-fetch on every pass.
|
||||
const evicted = galleryCache.srcById.prune(
|
||||
new Set([video.id, ...visibleIds.current, galleryCache.selectedId ?? ""]),
|
||||
);
|
||||
// The URL is cached above either way; skip the state update after unmount
|
||||
// (matches the other async callbacks in this file).
|
||||
if (isMounted.current) {
|
||||
setSrcById((prev) => ({ ...prev, [video.id]: url }));
|
||||
setSrcById((prev) => {
|
||||
const next = { ...prev, [video.id]: url };
|
||||
for (const id of evicted) delete next[id];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Leave it without a src; the card shows a placeholder.
|
||||
|
|
@ -726,15 +746,26 @@ export function VideoPage({ active = true }: { active?: boolean }) {
|
|||
// Tooltip trigger, whose asChild clone owns that ref. Re-runs per page of records, so cards
|
||||
// appended by "load more" are picked up and removed ones are dropped with the observer.
|
||||
const stripRef = useRef<HTMLDivElement | null>(null);
|
||||
// Ids currently intersecting the strip. The blob cache never evicts these, so pruning cannot
|
||||
// pull a clip out from under a visible card.
|
||||
const visibleIds = useRef<Set<string>>(new Set());
|
||||
useEffect(() => {
|
||||
const root = stripRef.current;
|
||||
if (!root || typeof IntersectionObserver === "undefined") return;
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
if (!entry.isIntersecting) continue;
|
||||
const id = (entry.target as HTMLElement).dataset.clipId;
|
||||
const clip = id ? videos.find((v) => v.id === id) : undefined;
|
||||
if (!id) continue;
|
||||
// Visibility is also the cache's recency and protection signal: an on-screen clip is
|
||||
// never evicted, and leaving the viewport makes it a candidate again.
|
||||
if (!entry.isIntersecting) {
|
||||
visibleIds.current.delete(id);
|
||||
continue;
|
||||
}
|
||||
visibleIds.current.add(id);
|
||||
galleryCache.srcById.touch(id);
|
||||
const clip = videos.find((v) => v.id === id);
|
||||
if (clip) void ensureSrc(clip);
|
||||
}
|
||||
},
|
||||
|
|
@ -833,9 +864,8 @@ export function VideoPage({ active = true }: { active?: boolean }) {
|
|||
toast.error(err instanceof Error ? err.message : "Failed to delete video");
|
||||
return;
|
||||
}
|
||||
const url = galleryCache.srcById.get(id);
|
||||
if (url?.startsWith("blob:")) URL.revokeObjectURL(url);
|
||||
galleryCache.srcById.delete(id);
|
||||
galleryCache.srcById.delete(id); // revokes the URL with the entry
|
||||
visibleIds.current.delete(id);
|
||||
// A fetch still in flight for this id must throw its blob away rather than cache it.
|
||||
galleryCache.deleted.add(id);
|
||||
setSrcById((prev) => {
|
||||
|
|
@ -854,10 +884,8 @@ export function VideoPage({ active = true }: { active?: boolean }) {
|
|||
toast.error(err instanceof Error ? err.message : "Failed to clear gallery");
|
||||
return;
|
||||
}
|
||||
for (const url of galleryCache.srcById.values()) {
|
||||
if (url.startsWith("blob:")) URL.revokeObjectURL(url);
|
||||
}
|
||||
galleryCache.srcById.clear();
|
||||
galleryCache.srcById.clear(); // revokes every cached URL
|
||||
visibleIds.current.clear();
|
||||
// Every fetch in flight now belongs to a cleared gallery, so their blobs are discarded on
|
||||
// arrival. The epoch covers ids this page never listed too.
|
||||
galleryCache.epoch += 1;
|
||||
|
|
|
|||
106
studio/frontend/src/lib/blob-url-cache.ts
Normal file
106
studio/frontend/src/lib/blob-url-cache.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
// A byte-budgeted LRU of object URLs for auth-protected gallery media.
|
||||
//
|
||||
// Gallery images and clips can't be plain <img>/<video> src attributes (the endpoints need the
|
||||
// auth header), so each one is fetched into a blob and wrapped in an object URL. An object URL
|
||||
// pins its blob until it is revoked, and the galleries keep their page component mounted after
|
||||
// the first visit, so an unbounded map of them grows for the whole session: a video clip is tens
|
||||
// to hundreds of MB, and scrolling a few pages of the strip could pin gigabytes in the webview.
|
||||
//
|
||||
// Budgeting by BYTES rather than by entry count is the point: clip sizes vary by two orders of
|
||||
// magnitude, so any entry cap is either useless for long clips or wasteful for short ones.
|
||||
//
|
||||
// Recency is driven by the caller (``touch`` on the visibility signal that already exists for
|
||||
// near-viewport fetching), and ``prune`` never evicts an id the caller marks as protected -- the
|
||||
// on-screen and selected media stay resident no matter how small the budget is.
|
||||
|
||||
export interface CachedBlobUrl {
|
||||
url: string;
|
||||
bytes: number;
|
||||
}
|
||||
|
||||
export class BlobUrlCache {
|
||||
// Insertion order IS the LRU order: touch() re-inserts, so the oldest use is always first.
|
||||
private readonly entries = new Map<string, CachedBlobUrl>();
|
||||
private totalBytes = 0;
|
||||
|
||||
constructor(private readonly budgetBytes: number) {}
|
||||
|
||||
has(id: string): boolean {
|
||||
return this.entries.has(id);
|
||||
}
|
||||
|
||||
get(id: string): string | undefined {
|
||||
return this.entries.get(id)?.url;
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.entries.size;
|
||||
}
|
||||
|
||||
get bytes(): number {
|
||||
return this.totalBytes;
|
||||
}
|
||||
|
||||
/** All cached ids, least recently used first. */
|
||||
ids(): string[] {
|
||||
return [...this.entries.keys()];
|
||||
}
|
||||
|
||||
/** ``{id: url}``, for seeding a component's render state on mount. */
|
||||
toRecord(): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const [id, entry] of this.entries) out[id] = entry.url;
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Mark ``id`` as most recently used. No-op for an id that is not cached. */
|
||||
touch(id: string): void {
|
||||
const entry = this.entries.get(id);
|
||||
if (entry === undefined) return;
|
||||
this.entries.delete(id);
|
||||
this.entries.set(id, entry);
|
||||
}
|
||||
|
||||
/** Cache ``url`` for ``id``. Replacing an id revokes the URL it had. */
|
||||
set(id: string, url: string, bytes: number): void {
|
||||
this.delete(id);
|
||||
this.entries.set(id, { url, bytes });
|
||||
this.totalBytes += bytes;
|
||||
}
|
||||
|
||||
/** Drop ``id`` and revoke its URL. Returns whether anything was cached. */
|
||||
delete(id: string): boolean {
|
||||
const entry = this.entries.get(id);
|
||||
if (entry === undefined) return false;
|
||||
this.entries.delete(id);
|
||||
this.totalBytes -= entry.bytes;
|
||||
URL.revokeObjectURL(entry.url);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Drop and revoke everything. */
|
||||
clear(): void {
|
||||
for (const entry of this.entries.values()) URL.revokeObjectURL(entry.url);
|
||||
this.entries.clear();
|
||||
this.totalBytes = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evict least-recently-used entries until the total is within budget, skipping ``protectedIds``.
|
||||
* Returns the evicted ids so the caller can drop them from its render state; those cards then
|
||||
* re-fetch if they come back into view.
|
||||
*/
|
||||
prune(protectedIds: Iterable<string> = []): string[] {
|
||||
const keep = protectedIds instanceof Set ? protectedIds : new Set(protectedIds);
|
||||
const evicted: string[] = [];
|
||||
for (const id of [...this.entries.keys()]) {
|
||||
if (this.totalBytes <= this.budgetBytes) break;
|
||||
if (keep.has(id)) continue;
|
||||
if (this.delete(id)) evicted.push(id);
|
||||
}
|
||||
return evicted;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue