fix: a saved pipeline the server refuses is said as a refusal, not an outage; Continuity reads the run's frozen settings; readiness checks bf16 on the card ComfyUI uses

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-10-05 14:47:51 +03:00
co-authored by Claude Opus 5.5
parent 8399b9f196
commit bd00692f85
7 changed files with 41 additions and 15 deletions
+3 -2
View File
@@ -28,8 +28,9 @@ export default {
return { inputs: withIt.overrides, notes: withIt.unwired > without.unwired ? ["The identity picture was not used: this pipeline has no free reference input for it."] : [] };
};
const carryHook = async ({ project, scene, slots }) => {
if (app.pipeline.allOff?.()) return {}; // Disable all enhancements means a plain run
const mine = (app.pipeline.currentValues() || {}).continuity;
// Read from the pipeline this run was frozen with, not the live one an edit may have changed since the click.
if (app.pipeline.allOff?.(slots)) return {}; // Disable all enhancements means a plain run
const mine = (app.pipeline.currentValues(slots) || {}).continuity;
if (!mine) return {}; // the module that holds the choices is not here: this does nothing at all
const carry = mine.carry !== false, guard = mine.dark_guard !== false;
const slot = startSlot(slots);
+2 -2
View File
@@ -14,7 +14,7 @@ export async function call(method, path, body) {
body: body === undefined ? undefined : JSON.stringify(body),
});
const payload = await res.json().catch(() => null);
if (!res.ok) throw new Error(why(res, payload));
if (!res.ok) throw Object.assign(new Error(why(res, payload)), { status: res.status }); // status: a refusal (4xx) is not an outage
return payload;
}
@@ -118,7 +118,7 @@ export const api = {
try {
const res = await fetch(`${BASE}/api/media`, { method: "POST", body: form });
const payload = await res.json().catch(() => null);
if (!res.ok) throw new Error(why(res, payload));
if (!res.ok) throw Object.assign(new Error(why(res, payload)), { status: res.status }); // status: a refusal (4xx) is not an outage
out.media.push(...(payload.media || [])); out.problems.push(...(payload.problems || []));
} catch (err) { out.problems.push(`${file.name}: ${err.message}`); }
}
+2 -2
View File
@@ -38,7 +38,7 @@ export function linkPipeline({ project, pipeline, onOpen, say = () => {} }) {
retry = setTimeout(adopt, 5000);
} else if (!pipeline.slots() || (pipeline.unreachable && pipeline.unreachable())) {
// ComfyUI is down: keep asking, slowly; and if a panel's own load gets there first, its announcement puts this project's in.
waiting = true;
waiting = pipeline.slots() ? "retry" : "load"; // "load": nothing loaded at all, so a panel's own load may get there first
retry = setTimeout(adopt, 15000);
if (retries === 4) say("ComfyUI is not answering, so this project's pipeline is not loaded yet: it goes in as soon as ComfyUI answers.");
} else {
@@ -47,7 +47,7 @@ export function linkPipeline({ project, pipeline, onOpen, say = () => {} }) {
}
pipeline.subscribe((slots) => {
if (waiting && owner === null) { retries = 0; adopt(); return; }
if (waiting === "load" && owner === null) { retries = 0; adopt(); return; }
if (!project.project || project.project.id !== owner) return;
store(project.project.models || {}, slots);
});
+11 -7
View File
@@ -104,10 +104,10 @@ export function createPipelineState(rawApi, { answerMs = ANSWER_MS } = {}) {
// plain object -- and only accepts a decoded key that names a module this
// session already knows is installed, so an unrelated node's incidentally
// JSON-shaped string can't inject a bogus entry that round-trips forever.
function valuesAlreadyPlaced() {
function valuesAlreadyPlaced(from = slots) {
const known = new Set([...Object.keys(modulesById), "_off"]); // _off: the modules this project turned off
const merged = {};
(slots || []).forEach((slot) => {
(from || []).forEach((slot) => {
Object.values(slot.inputs || {}).forEach((v) => {
if (typeof v !== "string") return;
let parsed;
@@ -273,18 +273,19 @@ export function createPipelineState(rawApi, { answerMs = ANSWER_MS } = {}) {
// can never hold a stale copy of each other's CONFIRMED state between
// them -- the pending overlay above is the one deliberate exception,
// needed so an in-flight edit from either editor isn't lost by the other.
function currentValues() {
// `from`: a pipeline other than the live one (Generate's, frozen at the click); edits not yet landed then do not count.
function currentValues(from) {
const merged = {};
Object.values(modulesById).forEach((m) => {
const own = {};
Object.entries(m.settings || {}).forEach(([name, spec]) => { own[name] = spec.default; });
if (Object.keys(own).length) merged[m.id] = own;
});
const already = valuesAlreadyPlaced();
const already = valuesAlreadyPlaced(from || slots);
Object.entries(already).forEach(([moduleId, own]) => {
merged[moduleId] = { ...(merged[moduleId] || {}), ...own };
});
Object.entries(pendingValues).forEach(([moduleId, own]) => {
Object.entries(from ? {} : pendingValues).forEach(([moduleId, own]) => {
merged[moduleId] = { ...(merged[moduleId] || {}), ...own };
});
return merged;
@@ -304,7 +305,7 @@ export function createPipelineState(rawApi, { answerMs = ANSWER_MS } = {}) {
}
// "Disable all enhancements": one project switch that makes every run plain, for A/B tests. The per-module
// choices are kept underneath, so switching it back restores exactly what was on.
const allOff = () => Boolean((currentValues()._off || {}).all);
const allOff = (from) => Boolean((currentValues(from)._off || {}).all);
const setAllOff = (on) => setModuleValue("_off", "all", Boolean(on));
// The screen is the truth: an edit is written into the live slots the moment it is made (the same blob the
// server's place() writes), so a run started now reads what is shown without waiting for any round trip.
@@ -591,7 +592,10 @@ export function createPipelineState(rawApi, { answerMs = ANSWER_MS } = {}) {
try { await refreshManifest(); } catch (_) { /* the next save retries it */ }
return true;
}
} catch (_) { unreachable = true; /* the request failed (not a refusal); try the next layering */ }
} catch (e) {
// No answer, or a gateway's (a tunnel, a restarting ComfyUI): try again later. An answer refusing it is a refusal.
if (!e || !e.status || e.status >= 502) unreachable = true;
}
}
unreachableLast = unreachable;
// Nothing saved was accepted (a failed request, or a file the server refuses): the default
+22
View File
@@ -485,3 +485,25 @@ test("a project opened while ComfyUI is down never gets an earlier project's wai
await PS.ensureLoaded();
assert.strictEqual(PS.slots().find((s) => s.id === "model").inputs.file, "");
});
test("a saved pipeline the server answers with a refusal is a refusal, not an outage; no answer is an outage", async () => {
for (const [err, outage] of [[Object.assign(new Error("slot 0 role 0 has no input"), { status: 400 }), false], [new Error("Failed to fetch"), true], [Object.assign(new Error("Bad gateway"), { status: 502 }), true]]) {
const PS = load([]);
await PS.ensureLoaded();
const real = lastApi.editPipeline;
lastApi.editPipeline = async () => { throw err; };
assert.strictEqual(await PS.adopt([{ id: "model", node: "Loader", inputs: { file: "x" } }]), false);
assert.strictEqual(PS.unreachable(), outage, err.message);
lastApi.editPipeline = real;
}
});
test("settings can be read from a frozen pipeline, apart from the live one and edits not yet landed", async () => {
const PS = load([]);
lastApi.modules = async () => ({ modules: [{ id: "continuity", settings: { carry: { type: "bool", default: true } } }] });
await PS.adopt([{ id: "settings", node: "Sink", inputs: { settings: JSON.stringify({ continuity: { carry: true } }) } }]);
const frozen = structuredClone(PS.slots());
await PS.adopt([{ id: "settings", node: "Sink", inputs: { settings: JSON.stringify({ continuity: { carry: false }, _off: { all: true } }) } }]);
assert.deepStrictEqual([PS.currentValues(frozen).continuity.carry, PS.allOff(frozen)], [true, false]);
assert.deepStrictEqual([PS.currentValues().continuity.carry, PS.allOff()], [false, true]);
});
+1 -1
View File
@@ -62,7 +62,7 @@ def machine(disk_free_gb=None) -> List[dict]:
else:
props = torch.cuda.get_device_properties(gpu_index())
out.append(row("ok", f"{props.name}, {props.total_memory / 1024 ** 3:.0f} GB, sm_{props.major}{props.minor}"))
if not torch.cuda.is_bf16_supported():
if props.major < 8: # bf16 arrived with Ampere (sm_80), on the card ComfyUI uses
out.append(row("fail", "This GPU does not support bf16, which the H3 VAEs need."))
if props.major >= 12 and have("xformers"):
out.append(row("warn", "xformers has no masked-attention kernel for this GPU: start ComfyUI with --disable-xformers --use-sage-attention."))
-1
View File
@@ -52,6 +52,5 @@ def test_the_gpu_row_names_the_card_comfyui_uses(monkeypatch):
monkeypatch.setattr(r, "gpu_index", lambda: 1)
monkeypatch.setattr(torch.cuda, "get_device_properties",
lambda i: asked.append(i) or types.SimpleNamespace(name=f"GPU{i}", total_memory=32 * 1024 ** 3, major=12, minor=0))
monkeypatch.setattr(torch.cuda, "is_bf16_supported", lambda: True)
rows = r.machine()
assert asked == [1] and any("GPU1" in x["text"] for x in rows)