Files
olivv-cs-ComfyUI-FunPack/app/shell/run.js
T
digital-garbage 1185e2201f core, app: a decade of races closed -- log routes, restarts, results, defaults
Two routes silently shared /api/log; a pack's "updates available" badge
outlived the update; nothing server-side stopped a restart mid-generation,
and the fixes for that had their own dead end, a lock scoped to the wrong
event loop, an unlocked status fetch, and a check that only ran once
instead of at the moment it mattered. A run's result was lost outright if
the project it belonged to wasn't the one open when it finished -- on a
project switch or a page reload. A brand-new project inherited the
previous one's width, then inherited the wrong "default" for it.

Found and confirmed via an adversarial pass over 9dcd94c..HEAD, one round
per fix.
2026-09-06 18:08:48 +03:00

381 lines
15 KiB
JavaScript

// A generation, from Generate to a picture on screen.
//
// ComfyUI's own queue and its own socket, not a second one alongside them.
// FunPack has no queue of its own and must not grow one: two things tracking
// what is running is two things that can disagree, and the one the user
// believes is whichever spoke last. So this speaks to /prompt, /interrupt and
// /ws exactly as any ComfyUI client does, and holds only what the UI needs to
// draw -- which is a view of the server's state and never a copy of it.
//
// Everything the outside world provides is injected: `fetch`, `connect`, the
// client id. A run can then be driven end to end in a test without a server,
// and the test exercises this file rather than a mock of it.
export const IDLE = "idle";
export const QUEUED = "queued";
export const RUNNING = "running";
export const DONE = "done";
export const FAILED = "failed";
export const CANCELLED = "cancelled";
/** The socket, which is a different question from what a run is doing. */
export const OFFLINE = "offline"; // not opened yet
export const LIVE = "live";
export const LOST = "lost"; // dropped, and trying again
/** How long to wait before the next attempt: 1s, 2s, 4s ... capped at 30. */
export const backoff = (attempt) => Math.min(30_000, 1000 * 2 ** Math.max(0, attempt));
/** Where ComfyUI serves a produced file from. */
export function viewUrl(image, base = "") {
const q = new URLSearchParams({
filename: image.filename || "",
subfolder: image.subfolder || "",
type: image.type || "output",
});
return `${base}/view?${q}`;
}
export function createRun({
fetch: doFetch = globalThis.fetch,
connect,
clientId,
base = "",
} = {}) {
const listeners = new Set();
let state = {
phase: IDLE, promptId: null, progress: null, images: [], audio: [],
error: null, node: null,
// Whether this page can still hear ComfyUI at all. Part of the run state
// rather than a second thing to subscribe to: one state object is what
// stops two parts of the UI disagreeing about what is happening.
connection: OFFLINE,
};
const emit = (next) => {
state = { ...state, ...next };
for (const fn of listeners) {
try { fn(state); } catch { /* one listener must not stop the others */ }
}
};
// Only messages carrying OUR prompt id are acted on. ComfyUI broadcasts some
// of them (an interrupt is broadcast to every client), so a second tab
// generating would otherwise drive this one's progress bar and, worse, mark
// this run finished when it was somebody else's that ended.
//
// Strict about the id being KNOWN, which it is not for the length of the
// /prompt round trip. Treating "no id yet" as "everything is mine" adopted a
// stranger's result during that window -- this run went to Done, showing
// somebody else's picture, before it had started.
const mine = (data) => Boolean(state.promptId) && Boolean(data)
&& data.prompt_id === state.promptId;
// Messages that arrived before the id did. Ours cannot be told apart from
// anyone else's yet, so they are kept and sorted out once the id is known --
// dropping them would lose a run that finished inside the round trip.
// Bounded, because on a busy shared server this fills with other people's
// traffic and nothing here needs the older end of it.
const PENDING = 200;
let pending = [];
function settle(promptId) {
emit({ promptId });
const held = pending;
pending = [];
for (const message of held) handle(message);
// A cancel asked for while the id was unknown was never sent, because a
// null prompt_id is a GLOBAL interrupt at ComfyUI: it stops whatever any
// client is running. Now that the run has a name, it can be stopped by it.
if (cancelWanted) sendCancel();
}
function handle(message) {
const { type, data } = message || {};
if (!type) return;
// Not yet named: hold it rather than guess whose it is.
//
// Whenever the id is unknown, not only while queueing. A page that reloads
// during a generation opens its socket first and learns which run is its
// own a moment later, and everything arriving in between was being dropped
// -- including the whole finish of a run that ended inside that moment,
// which then left the app at Ready with the result lost.
if (!state.promptId) {
pending.push(message);
if (pending.length > PENDING) pending.shift();
return;
}
switch (type) {
case "execution_start":
if (mine(data)) emit({ phase: RUNNING, progress: null });
break;
case "progress_state": {
if (!mine(data)) break;
// One entry per active node; the run's progress is the node that is
// actually working. Summing them would report a percentage of a total
// nobody can know before the graph has run.
const nodes = Object.values(data.nodes || {});
const active = nodes.find((n) => n.state === "running") || nodes[nodes.length - 1];
if (active && active.max) {
emit({ phase: RUNNING, node: active.display_node_id ?? active.node_id,
progress: { value: active.value || 0, max: active.max } });
}
break;
}
case "executed": {
if (!mine(data)) break;
const output = data.output || {};
// Appended, not replaced: a graph may save more than once, and the
// second save is not a correction of the first.
if (output.images) emit({ images: [...state.images, ...output.images] });
if (output.audio) emit({ audio: [...state.audio, ...output.audio] });
break;
}
case "execution_success":
if (mine(data)) emit({ phase: DONE, progress: null, node: null });
break;
case "execution_error":
if (!mine(data)) break;
emit({
phase: FAILED, progress: null,
// ComfyUI's own words. Rewriting them would lose the one thing that
// says which node and why.
error: {
node: data.node_type || data.node_id,
message: data.exception_message || "the run failed",
traceback: data.traceback || null,
},
});
break;
case "execution_interrupted":
// Broadcast, so the prompt id is the only way to know it was ours.
if (data && data.prompt_id === state.promptId) {
emit({ phase: CANCELLED, progress: null, node: null });
}
break;
default:
break; // not ours to interpret
}
}
// A cancel asked for before the run had a name.
let cancelWanted = false;
async function sendCancel() {
cancelWanted = false;
const id = state.promptId;
// ComfyUI's own state-agnostic cancel: it interrupts a job that is running
// and dequeues one that is only pending, atomically, and answers
// {"cancelled": false} for an id that has already finished.
//
// /interrupt alone was not enough and looked like it was. It only consults
// the RUNNING half of the queue, so cancelling a job waiting behind another
// one returned 200, logged "not currently running, skipping interrupt", and
// let the job run in full when its turn came -- a cancel that did nothing
// and said nothing, which is worse than a cancel that fails.
let response;
try {
response = await doFetch(`${base}/api/jobs/${encodeURIComponent(id)}/cancel`, {
method: "POST",
headers: { "Content-Type": "application/json" },
});
} catch {
response = null;
}
if (response && response.ok) {
// A job that was only PENDING is dequeued and never runs, so no
// execution_interrupted is ever sent -- nothing would arrive to move the
// UI off "Queued", and the run would look stuck forever after a cancel
// that worked. A RUNNING job is left to the socket, which says when it
// actually stopped rather than when it was asked to.
const body = await response.json().catch(() => ({}));
if (body && body.cancelled && state.phase === QUEUED) {
emit({ phase: CANCELLED, progress: null, node: null });
}
return response;
}
// An older ComfyUI without that route. Interrupting is all it can do, and
// it is right for the case people hit most: a run that is under way.
return doFetch(`${base}/interrupt`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt_id: id }),
});
}
let socket = null;
let attempt = 0;
let waiting = null;
// A CLOSED socket is still an object. "Already listening" therefore cannot be
// "there is a socket": every drop -- a laptop sleeping, a tunnel to a rented
// box hiccuping, ComfyUI restarting -- was permanent for that tab, and
// silent. Progress and results simply stopped arriving while the GPU carried
// on, and only a manual reload brought them back.
//
// readyState is undefined on a stand-in socket, and a stand-in is a live one.
const alive = (s) => Boolean(s) && (s.readyState === undefined || s.readyState <= 1);
function listen() {
if (typeof connect !== "function") return null;
if (alive(socket)) return socket;
if (waiting) { clearTimeout(waiting); waiting = null; }
socket = connect(clientId);
if (!socket) return null;
socket.addEventListener("open", () => { attempt = 0; emit({ connection: LIVE }); });
socket.addEventListener("message", (event) => {
// Binary frames are previews, which nothing here consumes yet. A parse
// failure must not take the socket down with it.
if (typeof event.data !== "string") return;
try { handle(JSON.parse(event.data)); } catch { /* not for us */ }
});
// close AND error: a refused connection fires error without ever closing on
// some browsers, and a dropped one closes without an error on others.
const dropped = (which) => () => {
if (which !== socket) return; // an older socket letting go
emit({ connection: LOST });
retry();
};
socket.addEventListener("close", dropped(socket));
socket.addEventListener("error", dropped(socket));
return socket;
}
function retry() {
if (waiting) return;
const wait = backoff(attempt);
attempt += 1;
waiting = setTimeout(() => { waiting = null; listen(); }, wait);
// Not a reason to keep a process alive: under a test runner a pending timer
// is the difference between a suite that finishes and one that hangs.
if (waiting && typeof waiting.unref === "function") waiting.unref();
}
return {
get state() { return { ...state }; },
/** Prompt ids seen on the socket while this page had no run of its own.
* Whoever is reattaching can ask ComfyUI which of them belongs here. */
seen: () => [...new Set(pending.map((m) => m && m.data && m.data.prompt_id)
.filter(Boolean))],
subscribe(fn) {
listeners.add(fn);
// Guarded like every later delivery. Handing a subscriber the current
// state is a delivery too, and doing it bare meant one bad listener took
// down whoever was wiring the UI up -- at startup, before anything is on
// screen to say so.
try { fn(state); } catch { /* the same rule as emit */ }
return () => listeners.delete(fn);
},
handle, // for tests and for a socket owned elsewhere
listen,
/** Stop trying. Only a page being torn down wants this. */
stopListening() {
if (waiting) { clearTimeout(waiting); waiting = null; }
if (socket && typeof socket.close === "function") socket.close();
socket = null;
},
/**
* Queue a prompt. Resolves with the prompt id, or throws with the reason.
*
* `extra`, when given, rides in `extra_data` alongside `client_id` --
* ComfyUI stores and echoes it back on `/queue` and `/history`, which is
* the only way a reload can learn what a run in flight belongs to.
*/
async start(prompt, { extra } = {}) {
listen();
pending = [];
cancelWanted = false;
emit({ phase: QUEUED, promptId: null, progress: null, images: [], audio: [],
error: null, node: null });
let response;
try {
response = await doFetch(`${base}/prompt`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(extra ? { prompt, client_id: clientId, extra_data: extra }
: { prompt, client_id: clientId }),
});
} catch (err) {
// The queue could not be reached at all -- no ComfyUI, or the dev
// server, which serves the app and has no queue behind it.
emit({ phase: FAILED, error: { message: `the queue could not be reached: ${err.message}` } });
throw err;
}
const body = await response.json().catch(() => ({}));
if (!response.ok) {
// ComfyUI refuses a bad graph here, BEFORE anything loads a model, and
// says which node and why. That is the message worth showing.
const detail = body.error && (body.error.message || body.error.type);
emit({ phase: FAILED, error: {
message: detail || `the queue refused this graph (${response.status})`,
nodes: body.node_errors || null,
} });
throw new Error(detail || `queue refused: ${response.status}`);
}
settle(body.prompt_id || null);
return body.prompt_id;
},
/** Ask for the run to stop. */
async cancel() {
if (state.phase !== QUEUED && state.phase !== RUNNING) return false;
if (!state.promptId) {
// Nothing to name yet. Remembered rather than sent: ComfyUI reads a
// missing prompt_id as "interrupt whatever is running", which on a
// shared box is someone else's generation, and on this one is not
// necessarily the run the user is looking at.
cancelWanted = true;
return true;
}
await sendCancel();
// Not marked cancelled here: the server says when it actually stopped,
// and claiming it early is how a UI ends up showing "cancelled" over a
// run that is still burning GPU time.
return true;
},
/**
* Take over a run already in flight -- after a reload, the generation the
* previous page load queued. The id comes from ComfyUI's own queue, so this
* adopts a real run rather than assuming one.
*/
adopt(promptId, { running = true } = {}) {
if (!promptId) return false;
listen();
// A job waiting its turn is queued, not running. Saying "working" over a
// run that has not started is the same small lie as saying "cancelled"
// over one that has not stopped.
emit({ phase: running ? RUNNING : QUEUED, images: [], audio: [],
error: null, node: null });
settle(promptId);
return true;
},
images: () => state.images.map((image) => viewUrl(image, base)),
reset() {
pending = [];
cancelWanted = false;
emit({ phase: IDLE, promptId: null, progress: null, images: [],
audio: [], error: null, node: null });
},
};
}