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:
Daniel Han 2026-07-27 05:08:45 +00:00
commit 3f6057a2b2
16 changed files with 359 additions and 50 deletions

View file

@ -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

View file

@ -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:

View file

@ -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,

View file

@ -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.

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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 ───────────────────────────────────────────────────

View file

@ -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) => {

View file

@ -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

View file

@ -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;

View 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;
}
}