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.
This commit is contained in:
Daniel Han 2026-07-26 08:00:41 +00:00
commit 5411747726
2 changed files with 45 additions and 0 deletions

View file

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

View file

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