Cancel an evicted safetensors load, spare the arbiter for CPU-only chat, fetch clips lazily

Four fixes from the latest review round:

- The GPU arbiter's chat evictor only cancelled the llama.cpp side. The
  orchestrator publishes active_model_name once its worker reports success, so
  an in-flight safetensors load was visible only as an entry in loading_models
  and finished onto the GPU after ownership had transferred. Cancel every
  pending load, and give the safetensors branch the post-load ownership recheck
  the GGUF branch already had.
- A manual gpu_layers=0 GGUF load runs on the CPU with the GPUs hidden from the
  child, yet it took the arbiter unconditionally: it cancelled a running image
  or video generation for a model needing no VRAM, then held CHAT ownership so
  the next GPU workload unloaded it for nothing. Gate the acquire on the same
  predicate the launch-time CPU-only mask uses, as the image and video loaders
  gate on their resolved device.
- The staged-download hook subscribes per repo, not per job, so another job on
  the same repo advanced the staged queue (starting a load whose scoped files
  were still downloading) or wiped a queue that was still running. Compare the
  variant each callback carries, like the chat page's auto-load does.
- The video gallery fetched every record of a page into an object URL that
  lives until the page closes: 50 clips at tens to hundreds of MB each, for
  cards the user may never scroll to. Fetch a clip as its card nears the strip's
  edge, plus the selected one the player needs.
This commit is contained in:
Daniel Han 2026-07-26 19:29:54 +00:00
commit 0add1accfd
8 changed files with 400 additions and 16 deletions

View file

@ -45,6 +45,14 @@ def _evict_chat() -> None:
orchestrator = get_inference_backend()
if orchestrator.active_model_name:
orchestrator.unload_model(orchestrator.active_model_name)
# An in-flight safetensors load has no active_model_name yet (it is published only once the
# worker reports success), so the unload above misses it and the load would finish onto the
# GPU we just granted away. cancel_load discards the loading marker BEFORE tearing the worker
# down, so a load parked between retries (or in _wait_response) observes the removal and
# aborts instead of publishing. It runs off the lifecycle gate, which the load itself holds
# for its whole duration, so this cannot deadlock.
for pending in list(getattr(orchestrator, "loading_models", ()) or ()):
orchestrator.cancel_load(pending)
# Kill the subprocess too: its base CUDA context holds VRAM diffusion needs.
orchestrator._shutdown_subprocess(timeout = 5.0)
# The driver reclaims the killed VRAM asynchronously; wait for it to settle before diffusion

View file

@ -1412,6 +1412,35 @@ def chat_load_active() -> bool:
return _CHAT_LOADS_IN_FLIGHT > 0
def zero_vram_chat_load(
gpu_memory_mode: str,
gpu_layers: int,
extra_args: Optional[list] = None,
needs_mmproj: bool = False,
speculative_type: Optional[str] = None,
) -> bool:
"""Whether a GGUF load will hold no VRAM at all, decided from the request.
A deliberate manual zero-offload load runs on the CPU and is launched with the GPUs hidden
from the child (see ``_cpu_only_zero_offload``), so it neither needs the GPU arbiter nor
should evict an image/video pipeline for it. This mirrors that launch-time mask so both
agree: an mmproj, a GPU drafter, a device pin or a surviving tensor mode all keep the GPUs
visible, and the mask is skipped on Vulkan. ``--mmproj`` and ``--model-draft`` are added by
the backend rather than being present in ``extra_args``, so their intent is passed in.
"""
if gpu_memory_mode != "manual" or gpu_layers != 0:
return False
# Any speculative mode may launch a GPU drafter; only the request's own knobs are known
# here, so treat every non-empty selection as GPU-bearing rather than guess.
if needs_mmproj or speculative_type:
return False
if LlamaCppBackend._is_vulkan_backend():
return False
return not LlamaCppBackend._zero_offload_keeps_gpu_visible(
[str(arg) for arg in (extra_args or [])], os.environ
)
def hf_gguf_load_in_flight(hf_repo: str) -> bool:
"""Return whether a GGUF load is active for hf_repo."""
key = (hf_repo or "").strip().lower()
@ -2539,6 +2568,22 @@ class LlamaCppBackend:
"""Requested --gpu-layers for manual mode (-1 when not manual)."""
return self._gpu_layers
@property
def holds_no_vram(self) -> bool:
"""Whether the resident server is a confirmed zero-VRAM launch.
True only for a deliberate manual zero-offload load whose launched argv carried no GPU
companion, pin or tensor mode, so the child was started with the GPUs hidden. The GPU
arbiter uses this to leave an image/video pipeline alone when re-asserting ownership for
such a model. ``_gpu_offload_active`` is None when no GPU was detected at all (nothing to
arbitrate) and True when something still reached the GPU, so both keep the normal path.
"""
return (
self._gpu_memory_mode == "manual"
and self._gpu_layers == 0
and self._gpu_offload_active is False
)
@property
def n_cpu_moe(self) -> int:
"""MoE expert layers manual mode kept on CPU (--n-cpu-moe); 0 = none."""

View file

@ -4395,7 +4395,7 @@ async def _load_model_impl(
# validation so a doomed load (bad id, unsupported gpu_ids on GGUF, training 409) can't
# evict a working image/video model and then error. Mirrors the image/video loaders,
# which validate before acquire_for.
from core.inference.gpu_arbiter import acquire_for, current_owner, CHAT
from core.inference.gpu_arbiter import acquire_for, current_owner, release, CHAT
# ── Already-loaded check: skip reload if the exact model is active ──
backend = get_inference_backend()
@ -4433,8 +4433,11 @@ async def _load_model_impl(
_gguf_is_audio = getattr(llama_backend, "_is_audio", False)
# Requested GGUF chat model already resident: assert CHAT ownership (no-op when
# held) to correct a drifted arbiter owner. Guaranteed-success path, so evicting
# here is correct.
await asyncio.to_thread(acquire_for, CHAT)
# here is correct -- unless the resident server is a confirmed zero-VRAM one
# (manual zero offload, GPUs hidden from the child), which coexists with an
# image/video pipeline and so must not evict it to re-announce itself.
if not llama_backend.holds_no_vram:
await asyncio.to_thread(acquire_for, CHAT)
return LoadResponse(
status = "already_loaded",
model = model_log_label
@ -4645,13 +4648,36 @@ async def _load_model_impl(
# image and video loads do: a chat load holds no llama-server process until its GGUF has
# downloaded, so a competing Images/Video acquire in that window found nothing to evict
# and both then allocated VRAM at once. With the marker, that evictor cancels this load.
from core.inference.llama_cpp import chat_load_in_flight
from core.inference.llama_cpp import chat_load_in_flight, zero_vram_chat_load
await asyncio.to_thread(
acquire_for,
CHAT,
lambda: gguf_load_stack.enter_context(chat_load_in_flight()),
# ...but only when this load will actually use the GPU, exactly as the image and video
# loaders gate on their resolved device. A manual gpu_layers=0 GGUF load runs on the CPU
# with the GPUs hidden from the child, so taking the arbiter for it would cancel a
# running image/video generation for a model that needs no VRAM, and leave CHAT recorded
# as owner so the next GPU workload pointlessly unloads it.
chat_load_needs_gpu = not (
config.is_gguf
and await asyncio.to_thread(
zero_vram_chat_load,
request.gpu_memory_mode,
request.gpu_layers,
extra_llama_args,
bool(config.is_vision and not extra_args_disable_mmproj(extra_llama_args)),
request.speculative_type,
)
)
if chat_load_needs_gpu:
await asyncio.to_thread(
acquire_for,
CHAT,
lambda: gguf_load_stack.enter_context(chat_load_in_flight()),
)
else:
# The marker still goes up (the download manager's handshake reads it, and it keeps
# this load cancellable). Any stale CHAT claim is dropped AFTER the load, not here:
# this load may still be replacing a GPU-backed chat model, and releasing up front
# would let an image/video load allocate alongside the model not yet unloaded.
gguf_load_stack.enter_context(chat_load_in_flight())
# ── GGUF path: load via llama-server ──────────────────────
if config.is_gguf:
@ -4816,8 +4842,9 @@ async def _load_model_impl(
# An Images/Video acquire can land in the gap between the acquire above and
# load_model clearing the cancel event, so its cancellation is lost and this load
# spawns anyway. Ownership survives that gap: whoever took the GPU keeps it, and this
# load undoes itself rather than leaving two models resident on one device.
if current_owner() != CHAT:
# load undoes itself rather than leaving two models resident on one device. A
# zero-VRAM load never took ownership, so it has nothing to lose and never yields.
if chat_load_needs_gpu and current_owner() != CHAT:
await asyncio.to_thread(llama_backend.unload_model)
raise HTTPException(
status_code = 409,
@ -4826,6 +4853,13 @@ async def _load_model_impl(
"so the load was cancelled. Unload that model, then try again."
),
)
if not chat_load_needs_gpu:
# Zero-VRAM load done, and whatever GPU-backed chat model it replaced went with
# it, so drop a now-stale CHAT claim: leaving it would make the next image/video
# load "evict" a server holding nothing. Owner-guarded, so it no-ops when an
# image/video model took the GPU while this was loading -- which is fine, they
# coexist.
await asyncio.to_thread(release, CHAT)
logger.info(
f"Loaded GGUF model via llama-server: {model_log_label if native_grant_backed else config.identifier}"
@ -4945,6 +4979,23 @@ async def _load_model_impl(
detail = f"Failed to load model: {model_log_label if native_grant_backed else config.display_name}",
)
# Same guard the GGUF branch runs above: an Images/Video acquire can land in the gap
# between this load's cancellation and its publish, so the eviction is lost and the
# model lands anyway. Ownership survives that gap, so this load undoes itself rather
# than leaving two models resident on one device.
if current_owner() != CHAT:
await asyncio.to_thread(backend.unload_model, config.identifier)
# The worker's base CUDA context outlives the model unload, so kill it too --
# that VRAM is exactly what the image/video pipeline just took the GPU for.
await asyncio.to_thread(backend._shutdown_subprocess, 5.0)
raise HTTPException(
status_code = 409,
detail = (
"An image or video model took the GPU while this model was loading, "
"so the load was cancelled. Unload that model, then try again."
),
)
logger.info(
f"Loaded model: {model_log_label if native_grant_backed else config.identifier}"
)

View file

@ -249,3 +249,104 @@ def test_evict_chat_cancels_a_chat_load_that_has_not_spawned_yet(monkeypatch):
# The marker is released with the load, so a later eviction is a no-op again.
arb._evict_chat()
assert unloaded == [True]
def test_evict_chat_cancels_an_in_flight_safetensors_load(monkeypatch):
# The orchestrator publishes active_model_name only once its worker reports success, so an
# in-flight safetensors load is visible ONLY as an entry in loading_models. Gating the
# cancellation on active_model_name let that worker finish after ownership transferred and
# allocate the model alongside the image/video pipeline.
import core.inference as core_inference
import routes.inference as routes_inference
cancelled: list[str] = []
class _FakeLlama:
is_active = False
is_loaded = False
def unload_model(self):
pass
def _wait_for_vram_settle(self, *, since_kill):
pass
class _FakeOrchestrator:
active_model_name = None # not published yet: the load is still running
loading_models = {"unsloth/Qwen3-4B"}
def unload_model(self, name):
raise AssertionError("unload_model must not run for an unpublished load")
def cancel_load(self, name):
cancelled.append(name)
return True
def _shutdown_subprocess(self, timeout = 5.0):
pass
monkeypatch.setattr(routes_inference, "get_llama_cpp_backend", lambda: _FakeLlama())
monkeypatch.setattr(core_inference, "get_inference_backend", lambda: _FakeOrchestrator())
arb._evict_chat()
assert cancelled == ["unsloth/Qwen3-4B"]
def test_evict_chat_cancels_every_pending_load_over_a_live_snapshot(monkeypatch):
# cancel_load discards the marker it cancels, so iterate a snapshot rather than the live
# set (mutating during iteration raises) and cancel each pending entry.
import core.inference as core_inference
import routes.inference as routes_inference
cancelled: list[str] = []
class _FakeLlama:
is_active = False
is_loaded = False
def unload_model(self):
pass
def _wait_for_vram_settle(self, *, since_kill):
pass
class _FakeOrchestrator:
active_model_name = None
def __init__(self):
self.loading_models = {"a/one", "b/two"}
def cancel_load(self, name):
self.loading_models.discard(name)
cancelled.append(name)
return True
def _shutdown_subprocess(self, timeout = 5.0):
pass
orchestrator = _FakeOrchestrator()
monkeypatch.setattr(routes_inference, "get_llama_cpp_backend", lambda: _FakeLlama())
monkeypatch.setattr(core_inference, "get_inference_backend", lambda: orchestrator)
arb._evict_chat()
assert sorted(cancelled) == ["a/one", "b/two"]
assert orchestrator.loading_models == set()
def test_the_safetensors_load_yields_a_gpu_it_lost_while_loading():
# Mirror of the GGUF branch's guard: an Images/Video acquire can land in the gap between the
# eviction and the load's publish, so the load has to undo itself instead of leaving two
# models resident. Without it only the GGUF branch was safe.
from pathlib import Path
route_src = (Path(__file__).resolve().parent.parent / "routes" / "inference.py").read_text(
encoding = "utf-8"
)
load_impl = route_src[route_src.index("async def _load_model_impl") :]
unsloth_load = load_impl.index("success = await asyncio.to_thread(\n backend.load_model,")
tail = load_impl[unsloth_load:]
guard = tail.index("if current_owner() != CHAT:")
assert "await asyncio.to_thread(backend.unload_model, config.identifier)" in tail[guard:]
assert tail.index("status_code = 409", guard) > guard

View file

@ -1154,3 +1154,86 @@ def test_cmd_companion_ignores_cpu_forced_drafter():
# mmproj still counts even alongside a CPU drafter.
cmd = ["llama-server", "-md", "d.gguf", "--spec-draft-ngl", "0", "--mmproj", "p.gguf"]
assert has(cmd, {}) is True
# ── Zero-offload loads and the GPU arbiter ───────────────────────────
@pytest.fixture
def not_vulkan(monkeypatch):
# The Vulkan probe reads the installed prebuilt; pin it so these assert the predicate.
monkeypatch.setattr(LlamaCppBackend, "_is_vulkan_backend", staticmethod(lambda *a, **k: False))
def test_zero_vram_chat_load_only_for_a_deliberate_cpu_only_offload(not_vulkan):
# Manual + gpu_layers=0 is the one shape that launches with the GPUs hidden from the child,
# so it is the one shape allowed to skip the GPU arbiter. Auto (or any pinned layer count)
# puts the model on the GPU and must still evict an image/video pipeline.
zero = llama_cpp_module.zero_vram_chat_load
assert zero("manual", 0) is True
assert zero("auto", 0) is False
assert zero("manual", 1) is False
assert zero("manual", -1) is False
def test_zero_vram_chat_load_refuses_every_gpu_companion(not_vulkan):
# The launch-time mask keeps the GPUs visible for a device pin, tensor mode, an mmproj or a
# drafter, so those loads DO hold VRAM. --mmproj and --model-draft are added by the backend
# rather than carried in the extras, so their intent arrives as flags.
zero = llama_cpp_module.zero_vram_chat_load
assert zero("manual", 0, ["--device", "CUDA0"]) is False
assert zero("manual", 0, ["-dev", "CUDA0"]) is False
assert zero("manual", 0, ["--split-mode", "tensor"]) is False
assert zero("manual", 0, ["--model-draft", "/tmp/draft.gguf"]) is False
assert zero("manual", 0, [], True) is False
assert zero("manual", 0, [], False, "model") is False
# A CPU-pinned device and a CPU-forced drafter keep it zero-VRAM.
assert zero("manual", 0, ["--device", "none"]) is True
assert zero("manual", 0, ["--model-draft", "/tmp/d.gguf", "--spec-draft-ngl", "0"]) is True
def test_zero_vram_chat_load_is_skipped_on_vulkan(monkeypatch):
# Vulkan builds are exempt from the CPU-only mask at launch, so the arbiter gate must match.
monkeypatch.setattr(LlamaCppBackend, "_is_vulkan_backend", staticmethod(lambda *a, **k: True))
assert llama_cpp_module.zero_vram_chat_load("manual", 0) is False
def test_holds_no_vram_needs_the_launch_to_have_confirmed_it():
# The property reports the LAUNCHED server, so it follows _gpu_offload_active: True (something
# still reached the GPU) and None (no GPU detected at all) both keep the normal arbiter path.
backend = LlamaCppBackend()
backend._gpu_memory_mode = "manual"
backend._gpu_layers = 0
backend._gpu_offload_active = False
assert backend.holds_no_vram is True
backend._gpu_offload_active = True
assert backend.holds_no_vram is False
backend._gpu_offload_active = None
assert backend.holds_no_vram is False
backend._gpu_offload_active = False
backend._gpu_layers = 20
assert backend.holds_no_vram is False
backend._gpu_layers = 0
backend._gpu_memory_mode = "auto"
assert backend.holds_no_vram is False
def test_a_cpu_only_chat_load_does_not_take_the_gpu_arbiter():
# A load that needs no VRAM must not evict a resident Images/Video pipeline (nor leave CHAT
# recorded as owner, which would make the next GPU workload unload it for nothing). The
# in-flight marker still goes up, and the post-load ownership recheck -- which would 409 a
# load that never acquired -- is gated on the same flag.
route_src = (Path(_BACKEND_DIR) / "routes" / "inference.py").read_text(encoding = "utf-8")
load_impl = route_src[route_src.index("async def _load_model_impl") :]
assert "chat_load_needs_gpu = not (" in load_impl
gate = load_impl.index("chat_load_needs_gpu = not (")
acquire = load_impl.index("if chat_load_needs_gpu:", gate)
# The stale CHAT claim is dropped only AFTER the load: this load may still be replacing a
# GPU-backed chat model, and releasing before it would let an image/video load allocate
# alongside the model not yet unloaded.
release = load_impl.index("await asyncio.to_thread(release, CHAT)", acquire)
assert load_impl.index("if not chat_load_needs_gpu:", acquire) < release
assert load_impl.index("success = await load_with_tensor_fallback(", acquire) < release
assert "if chat_load_needs_gpu and current_owner() != CHAT:" in load_impl
# The already-loaded fast path re-asserts ownership too; same exemption.
assert "if not llama_backend.holds_no_vram:" in load_impl

View file

@ -59,14 +59,27 @@ export function useStagedDownload({
kind: DOWNLOAD_KIND.MODEL,
repoId: current?.repoId ?? "__staged_download_idle__",
activeVariant,
onComplete: () => {
// The listener subscription is per REPO, not per job, and one repo can have several jobs in
// flight -- the Models tab downloading a chat quant of the same repo, say. Each callback
// carries the variant it fired for, so drop the ones that aren't this staged entry: a
// sibling's completion would otherwise advance the queue (and load a model whose scoped
// files are still downloading), and its failure would wipe a queue still running. The chat
// page's auto-load filters the same way.
onComplete: (variant) => {
if ((variant ?? null) !== activeVariant) return;
const remaining = (queue ?? []).slice(1);
advance();
// Every entry is on disk, so the load will find its cache warm.
if (remaining.length === 0) onReady();
},
onError: () => setQueue(null),
onCancelled: () => setQueue(null),
onError: (variant) => {
if ((variant ?? null) !== activeVariant) return;
setQueue(null);
},
onCancelled: (variant) => {
if ((variant ?? null) !== activeVariant) return;
setQueue(null);
},
});
useEffect(() => {

View file

@ -701,6 +701,48 @@ export function VideoPage({ active = true }: { active?: boolean }) {
}
}, []);
// Fetching a clip pulls its whole MP4 into an object URL that lives until the page closes, so
// fetching a full gallery page (PAGE_SIZE records, each tens to hundreds of MB for a longer
// clip) up front pinned hundreds of MB -- gigabytes over a few "load more" pages -- for cards
// the user may never scroll to, and starved the one they were waiting on. Fetch a card as it
// nears the viewport instead; the tile already shows a spinner until its src lands.
// The cards are observed from here rather than through a ref on each tile: the tile is a
// 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);
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 (clip) void ensureSrc(clip);
}
},
// rootMargin is added to the ROOT's box only, never to an intermediate clipping ancestor,
// so the root has to be the strip itself: a card scrolled past its right edge is clipped
// by the strip, and a margin on the default viewport root would never reach it. The strip
// scrolls horizontally, so the sideways margin is the one that matters -- it starts a
// card's fetch a few tiles before it is scrolled to, so it is ready on arrival.
{ root, rootMargin: "0px 600px" },
);
for (const card of root.querySelectorAll("[data-clip-id]")) io.observe(card);
return () => io.disconnect();
}, [videos, ensureSrc]);
// The preview player is what the user actually watches, so the selected clip is fetched
// whether or not its card is on screen: a selection restored across a tab switch, a freshly
// generated clip, or a pick made just before the strip scrolls it out of view.
useEffect(() => {
if (!selected) return;
void (async () => {
await ensureSrc(selected);
})();
}, [selected, ensureSrc]);
const loadGallery = useCallback(async () => {
try {
const page = await getVideoGallery(0, PAGE_SIZE);
@ -708,7 +750,11 @@ export function VideoPage({ active = true }: { active?: boolean }) {
galleryCache.hasMore = page.has_more;
setVideos(page.videos);
setHasMore(page.has_more);
page.videos.forEach((video) => void ensureSrc(video));
// No visibility signal without IntersectionObserver (jsdom / an old webview), so keep the
// eager fetch there rather than render a strip of permanent spinners.
if (typeof IntersectionObserver === "undefined") {
page.videos.forEach((video) => void ensureSrc(video));
}
} catch {
// Best-effort: a failed gallery load shouldn't block the page.
}
@ -727,7 +773,9 @@ export function VideoPage({ active = true }: { active?: boolean }) {
});
galleryCache.hasMore = page.has_more;
setHasMore(page.has_more);
page.videos.forEach((video) => void ensureSrc(video));
if (typeof IntersectionObserver === "undefined") {
page.videos.forEach((video) => void ensureSrc(video));
}
} catch {
// transient; the user can scroll again to retry
} finally {
@ -1810,6 +1858,7 @@ export function VideoPage({ active = true }: { active?: boolean }) {
{(videos.length > 0 || busy === "generating") && (
<div
ref={stripRef}
className="hover-scrollbar flex shrink-0 items-stretch gap-2 overflow-x-auto border-t border-foreground/10 p-3"
onScroll={(e) => {
// Near the right edge: pull the next older page (infinite scroll).
@ -1829,6 +1878,7 @@ export function VideoPage({ active = true }: { active?: boolean }) {
<TooltipTrigger asChild={true}>
<button
type="button"
data-clip-id={video.id}
onClick={() => setSelectedId(video.id)}
className="relative flex h-16 w-24 shrink-0 flex-col justify-end overflow-hidden rounded-[10px] bg-muted/40 outline-none ring-1 ring-transparent transition-shadow hover:ring-border focus-visible:ring-2 focus-visible:ring-ring"
>

View file

@ -724,3 +724,36 @@ def test_chat_picker_routes_diffusion_picks_to_their_page():
assert "diffusionPageForTask" in body and "navigateToPage" in body
# Task-scoped pickers (already on those pages) must select normally.
assert "if (!task)" in body
def test_staged_download_callbacks_only_answer_their_own_variant():
"""subscribeJobListeners is per repo, not per job, so a staged entry hears every job on
that repo (the Models tab fetching a chat quant of the same repo, say). Each callback
carries the variant it fired for: without comparing it, a sibling job's completion advanced
the staged queue and started a load whose scoped files were still downloading, and its
failure wiped a queue that was still running."""
src = _read("features/hub/download-manager/use-staged-download.ts")
for callback in ("onComplete", "onError", "onCancelled"):
handler = re.search(rf"{callback}: \(variant\) => \{{\n(.*?)\n \}},", src, re.S)
assert handler, f"{callback} does not take the variant"
assert "(variant ?? null) !== activeVariant" in handler.group(1), callback
def test_video_gallery_fetches_clips_as_their_cards_come_into_view():
"""Each gallery record's src is a blob holding the whole MP4 until the page closes, so
fetching a full page of them up front pinned hundreds of MB (gigabytes across "load more"
pages) for cards the user may never scroll to. Fetch on visibility instead, and always
fetch the selected clip, since that is the one the preview player plays."""
src = _read("features/video/video-page.tsx")
assert "new IntersectionObserver(" in src
assert "ref={stripRef}" in src and "data-clip-id={video.id}" in src
assert 'root.querySelectorAll("[data-clip-id]")' in src
# rootMargin is added to the root box only, so the strip (the clipping scroller) has to
# BE the root, or the prefetch margin never reaches a card clipped past its edge.
assert '{ root, rootMargin: "0px 600px" }' in src
# The only surviving whole-page fetches are the no-IntersectionObserver fallbacks.
eager = list(re.finditer(r"page\.videos\.forEach\(\(video\) => void ensureSrc\(video\)\)", src))
assert eager, "the jsdom/old-webview fallback fetch is missing"
for match in eager:
assert 'typeof IntersectionObserver === "undefined"' in src[max(0, match.start() - 260) : match.start()]
assert re.search(r"if \(!selected\) return;\s*\n\s*void \(async \(\) => \{\s*\n\s*await ensureSrc\(selected\);", src)