From 5411747726f400dc4c028b9a6487a22d24035b7f Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Sun, 26 Jul 2026 08:00:41 +0000 Subject: [PATCH] Show the retained failure when a video page mounts after a failed job Mount-time recovery handled only phase=completed, so reloading the page after a multi-minute generation failed left an idle view with no diagnosis: the backend keeps the terminal failed record only until the next job, and nothing else survives the reload. Surface it the same way the poll does, filtering the cancelled sentinel. --- studio/backend/tests/test_video_backend.py | 39 +++++++++++++++++++ .../src/features/video/video-page.tsx | 6 +++ 2 files changed, 45 insertions(+) diff --git a/studio/backend/tests/test_video_backend.py b/studio/backend/tests/test_video_backend.py index f227c0f40e..e936ca7e94 100644 --- a/studio/backend/tests/test_video_backend.py +++ b/studio/backend/tests/test_video_backend.py @@ -8,6 +8,7 @@ stack loads.""" import contextlib import sys +import time import types import pytest @@ -1107,6 +1108,44 @@ def test_generate_progress_derives_total_steps_and_fraction(fake_runtime): assert gen["step"] == 5 and gen["fraction"] == 0.25 +def test_failed_background_generate_retains_terminal_error(fake_runtime, tmp_path, monkeypatch): + # A page mounted AFTER a background job failed (browser reload during a minutes-long + # generation) reads the outcome from this retained terminal record -- its only diagnosis, + # since nothing else survives the reload. So a failure must stay pollable as + # active=False + phase="failed" + error, repeatedly, until the next job replaces it. + backend = VideoBackend() + _load_gguf(backend, tmp_path) + + def _boom( + self, + *, + prompt = None, + negative_prompt = None, + num_inference_steps = None, + guidance_scale = None, + width = None, + height = None, + num_frames = None, + frame_rate = None, + generator = None, + callback_on_step_end = None, + **kwargs, + ): + raise ValueError("frames exceed the device memory") + + monkeypatch.setattr(type(backend._state.pipe), "__call__", _boom) + backend.begin_generate(prompt = "a clip") + deadline = time.monotonic() + 10 + while backend.generate_progress()["active"] and time.monotonic() < deadline: + time.sleep(0.01) + gen = backend.generate_progress() + assert gen["active"] is False + assert gen["phase"] == "failed" + assert gen["error"] == "frames exceed the device memory" + # Re-poll: a mount-time probe is a second read of the same record, not a one-shot drain. + assert backend.generate_progress()["phase"] == "failed" + + def test_cache_bytes_counts_incomplete_blobs(fake_runtime, tmp_path, monkeypatch): # scan_cache_dir skips in-flight *.incomplete blobs, so the old counter froze at the # last completed blob for the whole multi-GB shard pull. The walk must count both, diff --git a/studio/frontend/src/features/video/video-page.tsx b/studio/frontend/src/features/video/video-page.tsx index 2efa7fde6d..291e8e2256 100644 --- a/studio/frontend/src/features/video/video-page.tsx +++ b/studio/frontend/src/features/video/video-page.tsx @@ -1002,6 +1002,12 @@ export function VideoPage({ active = true }: { active?: boolean }) { const clip = g.video; setVideos((prev) => (prev.some((v) => v.id === clip.id) ? prev : [clip, ...prev])); void ensureSrc(clip); + } else if (g.phase === "failed") { + // The other terminal phase, and the backend keeps it only until the next job: without + // this a reload after a minutes-long generation failed shows an idle page and the + // error (OOM, bad input, disk) is lost. Same cancelled-sentinel filter the poll uses. + const msg = g.error || "Video generation failed"; + if (!msg.toLowerCase().includes("cancelled")) toast.error(msg); } } catch { // Resume is best-effort; a failed probe just leaves the idle view.