Stop button also stops Upscale and Interpolate jobs

Jobs end as stopped (kept for the poll, no half file, no restarted message); Stop shows while one runs.
Python and JS tests for the cancel path.
This commit is contained in:
digital-garbage
2026-11-05 15:16:47 +02:00
parent 7d3baca3c6
commit 9c4458307c
10 changed files with 160 additions and 25 deletions
+9 -5
View File
@@ -2,6 +2,7 @@
import { composer as c } from "../../composer/composer.js";
import { isGenerative, isVideoClip, genUnitId, unitRoot, effFrames, effFps } from "../../shell/scenes.js";
import { QUEUED, RUNNING } from "../../shell/run.js";
import { jobsRunning, onJobs, stopJobs } from "../../shell/renders.js";
import { inPlace } from "../../shell/place.js";
import { buildInputs, rolesAt } from "./inputs.js";
import { offer } from "../../shell/actions.js";
@@ -201,15 +202,18 @@ export default {
let drawn = "";
function draw() {
const open = p.project;
const picked = open ? pickedScenes().length : 0, key = `${busy}|${held}|${g.state().phase}|${Boolean(open)}|${picked}`;
const picked = open ? pickedScenes().length : 0, key = `${busy}|${held}|${g.state().phase}|${Boolean(open)}|${picked}|${jobsRunning()}`;
if (key === drawn) return; // progress ticks must not rebuild the buttons
drawn = key;
const working = busy || held || g.state().phase === QUEUED || g.state().phase === RUNNING; // also a run this page did not start
const go = c.button.sm({ label: working ? "Generating…" : "▶ Generate", tone: "primary", disabled: working || !open, title: "Generate every scene on the timeline, except left-out scenes, video clips and Auto Montage pieces. An earlier render of a scene stays as a take.",
onClick: () => runUnits(unitsOf(p.scenes)) });
const one = c.button.sm({ label: picked > 1 ? `Selected (${picked})` : "Selected", title: "Generate only the picked scenes", disabled: working || !picked, onClick: () => runUnits(unitsOf(pickedScenes())) });
const stop = c.button.sm({ label: "■ Stop", tone: "danger", title: "Stop the run that is going", onClick: () => { stopped = true; g.cancel(); } });
stop.node.hidden = !working;
const stop = c.button.sm({ label: "■ Stop", tone: "danger", title: "Stop the run that is going, and any Upscale or Interpolate in progress", onClick: () => {
stopJobs(app.api.stopJob);
if (working) { stopped = true; g.cancel(); }
} });
stop.node.hidden = !working && !jobsRunning();
put(go.node, one.node, stop.node);
}
// A run found after a reload belongs to this page's last session: its result still goes on its clips.
@@ -244,11 +248,11 @@ export default {
});
});
draw();
const offRun = g.subscribe(draw);
const offRun = g.subscribe(draw), offJobs = onJobs(draw); // the Stop button also shows while an Upscale or Interpolate runs
const offApp = app.on((what) => { if (what === "generate.scene" && p.selected) runUnits(unitsOf([p.selected]));
else if (what === "generate.selected" && pickedScenes().length) runUnits(unitsOf(pickedScenes())); else draw(); });
const offers = [offer(app, { id: "generate-all", label: "Generate", icon: "▶", run: () => { if (p.project) runUnits(unitsOf(p.scenes)); } }),
offer(app, { id: "generate-selected", label: "Generate selected", icon: "▶", run: () => { if (pickedScenes().length) runUnits(unitsOf(pickedScenes())); } })];
return () => { offRun(); offApp(); offers.forEach((f) => f()); };
return () => { offRun(); offJobs(); offApp(); offers.forEach((f) => f()); };
},
};
+1 -1
View File
@@ -48,7 +48,7 @@ export default {
const swapped = await p.editFor(pid, (pr) => swap(pr, media, out) > 0);
if (!swapped) c.toast.warn({ text: "Interpolated, but that render was replaced meanwhile, so nothing was swapped." });
else c.toast.good({ text: `Interpolated: the render now plays at ${opts().multiplier} times the frames.` });
} catch (err) { c.toast.warn({ text: `Interpolate failed: ${err.message}` }); }
} catch (err) { c.toast[err.stopped ? "info" : "warn"]({ text: err.stopped ? "Interpolate stopped. The render stays as it was." : `Interpolate failed: ${err.message}` }); }
finally { busy.delete(key); draw(); }
}
+18 -1
View File
@@ -1,7 +1,7 @@
import test from "node:test";
import assert from "node:assert/strict";
import { interpolate, modeLabel } from "./job.js";
import { derived, gaveUp, onGaveUp, swap, wentThrough } from "../../shell/renders.js";
import { derived, gaveUp, onGaveUp, swap, wentThrough, waitJob, jobsRunning, onJobs, stopJobs, JobStopped } from "../../shell/renders.js";
const m = (f, sub = "") => ({ filename: f, subfolder: sub, type: "output" });
@@ -46,3 +46,20 @@ test("a step that gave up is announced, so a step waiting behind it can go on wi
gaveUp(m("r.mp4"), "upscale");
assert.deepEqual(heard, [["r.mp4", "upscale"]]);
});
test("Stop: a job being waited for can be stopped, ends as stopped (not failed, not restarted), and the Stop button hears about it", async () => {
const stopped = [], heard = [];
const off = onJobs(() => heard.push(jobsRunning()));
let polls = 0;
const read = async () => (++polls < 2 ? { state: "running" } : { state: "stopped" });
const waiting = waitJob(read, "j1", async () => { if (polls === 0) await stopJobs((id) => stopped.push(id)); });
await assert.rejects(waiting, (err) => err instanceof JobStopped && err.stopped === true && err.message === "Stopped.");
assert.deepEqual(stopped, ["j1"]);
assert.deepEqual(heard, [1, 0]); // shown while it waits, hidden when it ends
assert.equal(jobsRunning(), 0);
off();
});
test("Stop: a stopped job that is read once more is still there (no 404 turned into 'ComfyUI restarted')", async () => {
await assert.rejects(waitJob(async () => ({ state: "stopped" }), "j2", async () => {}), (err) => err.stopped === true);
});
+4 -1
View File
@@ -53,7 +53,10 @@ export default {
const swapped = await p.editFor(pid, (pr) => swap(pr, media, out) > 0);
if (!swapped) c.toast.warn({ text: "Upscaled, but that render was replaced meanwhile, so nothing was swapped." });
else c.toast.good({ text: "Upscaled: the render now plays the larger video." });
} catch (err) { gaveUp(media, "upscale"); c.toast.warn({ text: `Upscale failed: ${err.message}` }); }
} catch (err) {
if (err.stopped) c.toast.info({ text: "Upscale stopped. The render stays as it was." });
else { gaveUp(media, "upscale"); c.toast.warn({ text: `Upscale failed: ${err.message}` }); }
}
finally { busy.delete(key); draw(); }
}
+2
View File
@@ -138,6 +138,8 @@ export const api = {
/** The upscale is a background job of FunUI, not a ComfyUI prompt (that would make ComfyUI drop the workflow's loaded models). -> {job_id}. */
upscale: (media, model, rtx) => call("POST", "/api/m/render/upscale", { media: { filename: media.filename, subfolder: media.subfolder || "", type: media.type === "temp" ? "temp" : "output" }, model, ...(rtx ? { rtx_options: rtx } : {}) }),
upscaleJob: (id) => call("GET", `/api/m/render/upscale/${encodeURIComponent(id)}`),
/** The Stop button for Upscale and Interpolate jobs (one job list serves both). -> {stopped} */
stopJob: (id) => call("POST", `/api/m/render/jobs/${encodeURIComponent(id)}/stop`),
frameGeneration: () => call("GET", "/api/m/interpolate/frame_generation"),
/** Interpolation is a background job of FunUI, not a ComfyUI prompt (that would make ComfyUI drop the workflow's loaded models). -> {job_id}. */
interpolate: (media, options) => call("POST", "/api/m/interpolate/run", { media: { filename: media.filename, subfolder: media.subfolder || "", type: media.type === "temp" ? "temp" : "output" }, options }),
+28 -12
View File
@@ -2,21 +2,37 @@
// scene that plays it, and remembering which steps a file has been through so two "always" modes can never feed each other forever. Pure of the page.
export const keyOf = (m) => `${m.type || "output"}/${m.subfolder || ""}/${m.filename}`;
/** Wait for a FunUI background job. `read(id)` -> {state: running | done | error, media, detail}. -> the media, or throws a sentence. `sleep` is injectable so a test need not wait. */
const active = new Set(); // ids of the Upscale / Interpolate jobs this page is waiting for
const listeners = [];
const changed = () => { for (const fn of listeners) { try { fn(); } catch { /* one listener must not stop the others */ } } };
/** How many jobs the Stop button can stop right now. */
export const jobsRunning = () => active.size;
/** -> a function that stops listening. */
export const onJobs = (fn) => { listeners.push(fn); return () => { const i = listeners.indexOf(fn); if (i >= 0) listeners.splice(i, 1); }; };
/** The Stop button: ask every job this page is waiting for to stop. `stop(id)` is the API call. */
export const stopJobs = (stop) => Promise.all([...active].map((id) => Promise.resolve(stop(id)).catch(() => {})));
/** What waitJob throws for a job that was stopped: not a failure, so callers say "stopped" and not "failed". */
export class JobStopped extends Error { constructor() { super("Stopped."); this.stopped = true; } }
/** Wait for a FunUI background job. `read(id)` -> {state: running | done | error | stopped, media, detail}. -> the media, or throws a sentence (a JobStopped when it was stopped). `sleep` is injectable so a test need not wait. */
export async function waitJob(read, id, sleep = (ms) => new Promise((r) => setTimeout(r, ms))) {
let lost = 0;
for (;;) {
await sleep(1000);
let r;
try { r = await read(id); } catch (err) {
if (err.status === 404) throw new Error("ComfyUI restarted while it was working");
if (++lost < 30) continue; // a dropped reply is not a failed job
throw new Error("lost contact with ComfyUI");
active.add(id); changed();
try {
for (;;) {
await sleep(1000);
let r;
try { r = await read(id); } catch (err) {
if (err.status === 404) throw new Error("ComfyUI restarted while it was working");
if (++lost < 30) continue; // a dropped reply is not a failed job
throw new Error("lost contact with ComfyUI");
}
lost = 0;
if (r.state === "stopped") throw new JobStopped();
if (r.state === "error") throw new Error(r.detail || "failed inside ComfyUI");
if (r.state === "done") return r.media;
}
lost = 0;
if (r.state === "error") throw new Error(r.detail || "failed inside ComfyUI");
if (r.state === "done") return r.media;
}
} finally { active.delete(id); changed(); }
}
/** One file backs a whole chain run, so every scene render and ghost that plays `from` gets `to`. -> how many were swapped. */
+31 -3
View File
@@ -8,12 +8,32 @@ from collections import OrderedDict
MAX_JOBS = 50 # finished jobs are kept this long for the poll that reads them
_jobs: "OrderedDict[str, dict]" = OrderedDict()
_stop: set = set() # ids asked to stop; the job notices between chunks
class Stopped(Exception):
"""Raised inside a job that was asked to stop; its partial output is removed by whoever was writing it."""
def stop(job: str) -> bool:
"""Ask a queued or running job to stop. False when there is no such job or it already ended."""
entry = _jobs.get(job)
if not entry or entry.get("state") not in ("queued", "running"):
return False
_stop.add(job)
return True
def check(job: str) -> None:
"""Raises Stopped once `stop(job)` was called: the hook a long job calls between its chunks."""
if job in _stop:
raise Stopped()
def new() -> str:
job = uuid.uuid4().hex
_jobs[job] = {"state": "queued"}
for old in [k for k, v in _jobs.items() if v.get("state") in ("done", "error")]:
for old in [k for k, v in _jobs.items() if v.get("state") in ("done", "error", "stopped")]:
if len(_jobs) <= MAX_JOBS:
break
del _jobs[old] # only finished ones: a running job's poll must keep finding it
@@ -24,14 +44,22 @@ def get(job: str) -> dict | None:
return _jobs.get(job)
async def run(job: str, fn, *args, error) -> None:
async def run(job: str, fn, *args, error, stoppable: bool = False) -> None:
"""Run the blocking `fn(*args)` off the event loop and record how it ended. `error` is the
exception class whose message is meant for the person; anything else is reported plainly."""
if job in _stop: # stopped while it was still queued
_stop.discard(job)
_jobs[job] = {"state": "stopped"}
return
_jobs[job] = {"state": "running"}
try:
result = await asyncio.to_thread(fn, *args)
result = await asyncio.to_thread(fn, *args, **({"interrupted": lambda: check(job)} if stoppable else {}))
_jobs[job] = {"state": "done", **result}
except Stopped:
_jobs[job] = {"state": "stopped"}
except error as exc:
_jobs[job] = {"state": "error", "detail": str(exc)}
except Exception as exc: # noqa: BLE001
_jobs[job] = {"state": "error", "detail": f"{type(exc).__name__}: {exc}"}
finally:
_stop.discard(job)
+1 -1
View File
@@ -43,7 +43,7 @@ def routes(table, base, web):
except ValueError as exc:
return bad(str(exc))
job = jobs.new()
asyncio.create_task(jobs.run(job, nodes.interpolate, media, body.get("options") or {}, error=nodes.InterpolateError))
asyncio.create_task(jobs.run(job, nodes.interpolate, media, body.get("options") or {}, error=nodes.InterpolateError, stoppable=True))
return web.json_response({"job_id": job})
@table.get(base + "/jobs/{job}")
+6 -1
View File
@@ -102,9 +102,14 @@ def routes(table, base, web):
if not isinstance(media, dict) or not isinstance(media.get("filename"), str) or not media["filename"] or not isinstance(model, str) or not model:
return bad("Send {media: {filename, subfolder, type}, model, rtx_options}.")
job = jobs.new()
asyncio.create_task(jobs.run(job, upscale.run, media, model, body.get("rtx_options") or "{}", error=(RuntimeError, ValueError, files.ClipError)))
asyncio.create_task(jobs.run(job, upscale.run, media, model, body.get("rtx_options") or "{}", error=(RuntimeError, ValueError, files.ClipError), stoppable=True))
return web.json_response({"job_id": job})
@table.post(base + "/jobs/{job}/stop")
async def _stop_job(req):
"""Stop an Upscale or Interpolate job (the page's Stop button). Both ride this module's job list."""
return web.json_response({"stopped": jobs.stop(req.match_info["job"])})
@table.get(base + "/upscale/{job}")
async def _upscale_job(req):
job = jobs.get(req.match_info["job"])
@@ -149,3 +149,63 @@ def test_render_keeps_the_real_frame_rate_of_interpolated_clips_only_when_asked(
job = finish(server, f"{BASE}/projects/{proj.id}/render", queued["job_id"])
assert job["state"] == "done"
assert files.frame_rate(str(comfy.temp / job["media"]["filename"])) == expected
def test_a_running_job_asked_to_stop_ends_stopped_and_stays_pollable(monkeypatch):
import asyncio
import threading
monkeypatch.setattr(jobs, "MAX_JOBS", 3)
started, seen = threading.Event(), []
def long_job(interrupted):
started.set()
while True:
interrupted() # between chunks, as stream_file does
seen.append(1)
threading.Event().wait(0.01)
async def go():
job = jobs.new()
task = asyncio.create_task(jobs.run(job, long_job, error=RuntimeError, stoppable=True))
while not started.is_set():
await asyncio.sleep(0.01)
assert jobs.stop(job) is True
await task
return job
job = asyncio.run(go())
assert jobs.get(job) == {"state": "stopped"} # a poll finds it: no 404, so no "ComfyUI restarted"
assert jobs.stop(job) is False and jobs.stop("nope") is False
def test_a_job_stopped_before_it_started_never_runs():
import asyncio
ran = []
job = jobs.new()
assert jobs.stop(job) is True
asyncio.run(jobs.run(job, lambda interrupted: ran.append(1), error=RuntimeError, stoppable=True))
assert not ran and jobs.get(job) == {"state": "stopped"}
@needs_ffmpeg
def test_a_stopped_stream_leaves_no_half_written_file(comfy):
from core import video_pipe
src = comfy.output / "a.mp4"
make_clip(src, seconds=1)
dst = comfy.output / "out.mp4"
calls = []
def stop_after_first_chunk():
calls.append(1)
if len(calls) > 1:
raise jobs.Stopped()
with pytest.raises(jobs.Stopped):
video_pipe.stream_file(str(src), str(dst), lambda x: x, stop_after_first_chunk)
assert not dst.exists()
def test_the_stop_route_answers_whether_it_stopped_anything(comfy, server):
job = jobs.new()
status, body = call(server, "POST", f"{BASE}/jobs/{job}/stop")
assert status == 200 and body == {"stopped": True}
assert call(server, "POST", f"{BASE}/jobs/nope/stop")[1] == {"stopped": False}