fix: saved pipeline is validated before it is adopted, answers for a previous project are discarded, a failed load never overwrites the project's copy, pipeline edits persist within a second

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-10-02 23:44:57 +03:00
co-authored by Claude Sonnet 5.5
parent 0aac66d3e9
commit 5673f7513b
3 changed files with 85 additions and 15 deletions
+33 -11
View File
@@ -65,6 +65,7 @@
let queueable = false;
let loading = false;
let loadError = null;
let epoch = 0; // bumped whenever a project's pipeline replaces the live one
let saving = false;
let pending = false;
let pendingBody = null;
@@ -174,8 +175,12 @@
pending = false;
const toSend = { slots, ...pendingBody };
pendingBody = null;
const mine = epoch;
try {
const res = await API.editPipeline(toSend);
// A different pipeline was put in while this was in flight (another project opened):
// this answer is about the old one and must not overwrite the new.
if (mine !== epoch) continue;
if (res && res.slots) slots = res.slots;
incomplete = (res && res.incomplete) || [];
refused = (res && res.refused) || [];
@@ -289,20 +294,37 @@
async function adopt(saved) {
await ensureLoaded();
if (slots === null || !Array.isArray(saved) || !saved.length) return;
const byId = new Map(saved.filter((s) => s && typeof s.id === "string").map((s) => [s.id, s]));
// A saved slot is only what the server would accept: a project file outlives the code that
// wrote it (v4 files name the node differently), and one bad slot refuses every later edit.
const sound = (s) => s && typeof s.id === "string" && typeof s.node === "string"
&& s.inputs && typeof s.inputs === "object" && !Array.isArray(s.inputs);
const byId = new Map(saved.filter(sound).map((s) => [s.id, s]));
const have = new Set(offered.map((s) => s.id));
const merged = offered.map((def) => {
const lay = (extras) => offered.map((def) => {
const mine = byId.get(def.id);
if (!mine || mine.node !== def.node) return def;
const out = { ...def, inputs: { ...(def.inputs || {}), ...(mine.inputs || {}) } };
if (mine.group) out.group = mine.group;
if (mine.bypassed !== undefined) out.bypassed = mine.bypassed;
if (!mine || mine.node !== def.node) return JSON.parse(JSON.stringify(def));
const out = { ...def, inputs: { ...(def.inputs || {}), ...mine.inputs } };
if (typeof mine.group === "string" && mine.group) out.group = mine.group;
if (typeof mine.bypassed === "boolean") out.bypassed = mine.bypassed;
return out;
}).concat(saved.filter((s) => s && typeof s.id === "string" && !have.has(s.id)));
slots = merged;
const quiet = new Set(listeners);
listeners.clear();
try { await save({}); } finally { quiet.forEach((fn) => listeners.add(fn)); }
}).concat(extras ? saved.filter((s) => sound(s) && !have.has(s.id)) : []);
// Replaces whatever pipeline was live: nothing queued for the old one may land on the new.
epoch++;
pendingBody = null; pending = false;
while (saving) await new Promise((r) => setTimeout(r, 20));
for (const extras of [true, false]) {
try {
const res = await API.editPipeline({ slots: lay(extras) });
if (res && res.slots && !(res.refused || []).length) {
slots = res.slots;
incomplete = res.incomplete || []; refused = []; queueable = !!res.queueable;
saveNotes = res.notes || [];
return;
}
} catch (_) { /* this layering was refused; try the next */ }
}
slots = JSON.parse(JSON.stringify(offered)); // nothing saved was usable: the default, said
saveNotes = ["This project's saved pipeline could not be loaded, so the default is in use."];
}
window.PipelineState = {
+40 -2
View File
@@ -2,7 +2,7 @@ const test = require("node:test");
const assert = require("node:assert");
const fs = require("node:fs");
function load(posts) {
function load(posts, opts = {}) {
const defaults = [
{ id: "model", node: "Loader", group: "Loaders", inputs: { file: "", dtype: "bf16" } },
{ id: "gen", node: "Gen", group: "Sampling", inputs: { steps: 8, model: ["model", 0] } },
@@ -10,7 +10,12 @@ function load(posts) {
global.window = {
MovieEditorAPI: {
pipeline: async () => ({ slots: JSON.parse(JSON.stringify(defaults)), incomplete: [], refused: [], queueable: true }),
editPipeline: async (body) => { posts.push(body); return { slots: body.slots, incomplete: [], refused: [], queueable: true }; },
editPipeline: async (body) => {
posts.push(body);
if (opts.delay) await new Promise((r) => setTimeout(r, opts.delay));
const bad = (body.slots || []).findIndex((s) => typeof s.node !== "string");
if (bad >= 0) { const e = new Error(`slot ${bad} has no node`); throw e; } for (const [id, edits] of Object.entries(body.inputs || {})) { const sl = body.slots.find((x) => x.id === id); if (sl) sl.inputs = { ...sl.inputs, ...edits }; }
return { slots: body.slots, incomplete: [], refused: [], queueable: true }; },
modules: async () => ({ modules: [] }),
probeFamily: async () => ({}),
},
@@ -53,3 +58,36 @@ test("nothing saved leaves the session's pipeline as it is", async () => {
await PS.adopt([]);
assert.strictEqual(JSON.stringify(PS.slots()), before);
});
test("a v4-shaped or hostile saved slot cannot brick the pipeline", async () => {
const PS = load([]);
await PS.adopt([
{ id: "model", node: "Loader", inputs: { file: "h3.safetensors" } },
{ id: "abc123", node_class: "OldV4Node", inputs: {} },
{ id: "bad", node: "X", inputs: [1, 2] },
]);
assert.deepStrictEqual(PS.slots().map((s) => s.id), ["model", "gen"]);
assert.strictEqual(PS.slots()[0].inputs.file, "h3.safetensors");
await PS.save({ inputs: { model: { file: "new" } } });
assert.strictEqual(PS.slots()[0].inputs.file, "new");
});
test("an answer about the previous project's pipeline does not overwrite the one just opened", async () => {
const PS = load([], { delay: 30 });
await PS.ensureLoaded();
const inflight = PS.save({ inputs: { model: { file: "A-file" } } });
await new Promise((r) => setTimeout(r, 5));
await PS.adopt([{ id: "model", node: "Loader", inputs: { file: "B-file" } }]);
await inflight;
assert.strictEqual(PS.slots()[0].inputs.file, "B-file");
});
test("nothing loaded (ComfyUI unreachable) leaves slots null, never an empty pipeline", async () => {
const PS = load([]);
global.window.MovieEditorAPI.pipeline = async () => { throw new Error("down"); };
delete require.cache[require.resolve("./pipeline_state.js")];
require("./pipeline_state.js");
const P2 = global.window.PipelineState;
await P2.adopt([{ id: "model", node: "Loader", inputs: {} }]);
assert.strictEqual(P2.slots(), null);
});
+12 -2
View File
@@ -4699,12 +4699,19 @@
// copy of it (project.models) so a model pick and every node value travel with the project to
// a new rental. Opening a project puts ITS pipeline over the session's; a project with none
// yet inherits the session's.
let _modelsRetries = 0;
let _pipelineSaveTimer = null;
async function loadModels() {
const saved = (state.project && state.project.models && state.project.models.slots) || [];
const p = (async () => {
try {
await window.PipelineState.adopt(saved);
state.models = { slots: JSON.parse(JSON.stringify(window.PipelineState.slots() || [])) };
const live = window.PipelineState.slots();
// Not loaded (ComfyUI unreachable): the project's own copy stays exactly as it was -- an
// empty live pipeline must never be mistaken for "no pipeline" and saved over it. Tried again.
state.models = { slots: live ? JSON.parse(JSON.stringify(live)) : saved };
if (!live && _modelsRetries++ < 3) setTimeout(loadModels, 5000);
else if (live) _modelsRetries = 0;
} catch (_) { state.models = { slots: saved }; }
})();
_modelsLoad = p;
@@ -5008,7 +5015,10 @@
window.PipelineState.subscribe((slots) => {
if (!state.project) return;
state.models = { slots: JSON.parse(JSON.stringify(slots || [])) };
scheduleSaveSilent();
// Soon, and past a Settings window's hold: a model pick must survive the reload that follows it.
_localDirty = true;
clearTimeout(_pipelineSaveTimer);
_pipelineSaveTimer = setTimeout(() => { _pipelineSaveTimer = null; flushSave(); }, 800);
});
await refreshProjectList();
// Re-attach to a generation already running in ComfyUI (UI was reloaded mid-run) is