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.
127 lines
5.6 KiB
JavaScript
127 lines
5.6 KiB
JavaScript
// This tab's identity to ComfyUI, and its socket.
|
|
//
|
|
// The id is REMEMBERED, because ComfyUI addresses progress and results to the
|
|
// client that queued the run: a reload with a new id hears nothing about the
|
|
// generation already in flight -- the run continues, the GPU time is spent, and
|
|
// the app sits there saying Ready.
|
|
//
|
|
// Remembered per TAB, not per browser. ComfyUI keys its socket table by this
|
|
// id and evicts the old entry when a new socket arrives with the same one
|
|
// (`self.sockets.pop(sid, None)` on connect), so two tabs sharing an id means
|
|
// the second tab silently takes the first tab's updates -- the first freezes
|
|
// mid-progress while the GPU carries on. Worse, the socket's `finally` pops
|
|
// that id again on close, so closing the dead tab disconnects the live one.
|
|
// sessionStorage is exactly the right lifetime: it survives a reload of this
|
|
// tab, which is what reattach needs, and does not reach another tab.
|
|
//
|
|
// The one gap: duplicating a tab copies its sessionStorage, so a duplicate
|
|
// starts out sharing the id. That is the same collision, from a deliberate act,
|
|
// and there is nothing readable on the page that would tell the two apart.
|
|
|
|
const KEY = "funpack.client-id";
|
|
|
|
export function clientId() {
|
|
try {
|
|
const kept = window.sessionStorage.getItem(KEY);
|
|
if (kept) return kept;
|
|
} catch { /* private mode: a fresh id per load, which still works */ }
|
|
|
|
const fresh = (globalThis.crypto && globalThis.crypto.randomUUID)
|
|
? globalThis.crypto.randomUUID()
|
|
: `funpack-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
try { window.sessionStorage.setItem(KEY, fresh); } catch { /* not fatal */ }
|
|
return fresh;
|
|
}
|
|
|
|
/** ComfyUI's socket, on whatever origin served this page. */
|
|
export function connect(id) {
|
|
const scheme = window.location.protocol === "https:" ? "wss:" : "ws:";
|
|
return new WebSocket(`${scheme}//${window.location.host}/ws?clientId=${encodeURIComponent(id)}`);
|
|
}
|
|
|
|
/**
|
|
* What this browser already has in the queue: {promptId, running}, or null.
|
|
*
|
|
* Asked of ComfyUI's own queue rather than remembered locally: a run this page
|
|
* queued may have finished, been cancelled, or been dequeued while the page was
|
|
* gone, and a remembered id would reattach the UI to a run that no longer
|
|
* exists.
|
|
*
|
|
* BOTH halves. `/queue` answers with `queue_running` and `queue_pending`, and a
|
|
* job sits in the second one from the moment /prompt returns until the worker
|
|
* picks it up -- always briefly, and for as long as it takes whenever something
|
|
* is running ahead of it. Reading only the running half meant a reload in that
|
|
* window found nothing, handed Generate back, and let the user queue the same
|
|
* job twice: the first copy then ran unnamed, its messages failing the id check,
|
|
* burning time while the UI narrated the second.
|
|
*
|
|
* Items in both are [number, prompt_id, prompt, extra_data, outputs], and
|
|
* extra_data carries the client_id ComfyUI was given when the run was queued.
|
|
*/
|
|
export async function queuedFor(id, { fetch: doFetch = globalThis.fetch, base = "" } = {}) {
|
|
let response;
|
|
try {
|
|
response = await doFetch(`${base}/queue`);
|
|
} catch {
|
|
return null; // no queue reachable: nothing to reattach to
|
|
}
|
|
if (!response.ok) return null;
|
|
|
|
const body = await response.json().catch(() => ({}));
|
|
// Running first: if this browser somehow has both, the one under way is the
|
|
// one worth showing. Which half it came from is returned as well, because a
|
|
// job waiting its turn is not a job in progress, and telling the user it is
|
|
// working when nothing is happening yet is a small lie the UI does not need
|
|
// to tell.
|
|
for (const [half, running] of [[body.queue_running, true], [body.queue_pending, false]]) {
|
|
for (const item of half || []) {
|
|
if (!Array.isArray(item) || item.length < 4) continue;
|
|
const [, promptId, , extra] = item;
|
|
if (extra && extra.client_id === id && promptId) {
|
|
// Where the run belongs, if it said so when queued -- carried through
|
|
// so a reload mid-generation can still land the result on its scene
|
|
// instead of just knowing a run is going.
|
|
return { promptId, running, sceneId: extra.funpack_scene_id || null,
|
|
projectId: extra.funpack_project_id || null };
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Which of these runs was this browser's, among ones that have already
|
|
* finished.
|
|
*
|
|
* Only ids seen on this page's own socket are ever asked about, so nothing here
|
|
* can resurrect a result from an hour ago: a run that ended while the page was
|
|
* loading is the case this exists for, and it is indistinguishable from a lost
|
|
* result without it. A history entry's `prompt` is
|
|
* [number, prompt_id, prompt, extra_data, outputs].
|
|
*/
|
|
export async function finishedFor(id, candidates,
|
|
{ fetch: doFetch = globalThis.fetch, base = "", onFound } = {}) {
|
|
for (const promptId of [...(candidates || [])].reverse()) {
|
|
let response;
|
|
try {
|
|
response = await doFetch(`${base}/history/${encodeURIComponent(promptId)}`);
|
|
} catch {
|
|
return null; // no history reachable
|
|
}
|
|
if (!response.ok) continue;
|
|
|
|
const body = await response.json().catch(() => ({}));
|
|
const entry = body && body[promptId];
|
|
const prompt = entry && entry.prompt;
|
|
if (Array.isArray(prompt) && prompt.length > 3 && prompt[3]
|
|
&& prompt[3].client_id === id) {
|
|
// `onFound` hands back the extra_data this run was queued with, without
|
|
// changing what this function returns everywhere else it is called --
|
|
// where the run belongs is the reattach path's business, not this one's.
|
|
if (onFound) onFound(prompt[3]);
|
|
return promptId;
|
|
}
|
|
}
|
|
return null;
|
|
}
|